Work in Progress: Content Editing in Admin Section

This commit is contained in:
Robin Ward
2013-04-04 12:59:44 -04:00
parent bd0e98aec2
commit fa1ba6791b
39 changed files with 653 additions and 64 deletions

View File

@@ -0,0 +1,25 @@
/**
This controller is used for editing site content
@class AdminSiteContentEditController
@extends Ember.ObjectController
@namespace Discourse
@module Discourse
**/
Discourse.AdminSiteContentEditController = Discourse.ObjectController.extend({
saveDisabled: function() {
if (this.get('saving')) return true;
if (this.blank('content.content')) return true;
return false;
}.property('saving', 'content.content'),
saveChanges: function() {
var controller = this;
controller.setProperties({saving: true, saved: false});
this.get('content').save().then(function () {
controller.setProperties({saving: false, saved: true});
});
}
});

View File

@@ -0,0 +1,39 @@
/**
Our data model for interacting with custom site content
@class SiteContent
@extends Discourse.Model
@namespace Discourse
@module Discourse
**/
Discourse.SiteContent = Discourse.Model.extend({
markdown: Ember.computed.equal('format', 'markdown'),
plainText: Ember.computed.equal('format', 'plain'),
html: Ember.computed.equal('format', 'html'),
css: Ember.computed.equal('format', 'css'),
/**
Save the content
@method save
@return {jqXHR} a jQuery Promise object
**/
save: function() {
return Discourse.ajax(Discourse.getURL("/admin/site_contents/" + this.get('content_type')), {
type: 'PUT',
data: {content: this.get('content')}
});
}
});
Discourse.SiteContent.reopenClass({
find: function(type) {
return Discourse.ajax(Discourse.getURL("/admin/site_contents/" + type)).then(function (data) {
return Discourse.SiteContent.create(data.site_content);
});
}
});

View File

@@ -0,0 +1,21 @@
/**
Our data model that represents types of editing site content
@class SiteContentType
@extends Discourse.Model
@namespace Discourse
@module Discourse
**/
Discourse.SiteContentType = Discourse.Model.extend({});
Discourse.SiteContentType.reopenClass({
findAll: function() {
var contentTypes = Em.A();
Discourse.ajax(Discourse.getURL("/admin/site_content_types")).then(function(data) {
data.forEach(function (ct) {
contentTypes.pushObject(Discourse.SiteContentType.create(ct));
});
});
return contentTypes;
}
});

View File

@@ -14,19 +14,17 @@ Discourse.SiteCustomization = Discourse.Model.extend({
return this.startTrackingChanges();
},
description: (function() {
description: function() {
return "" + this.name + (this.enabled ? ' (*)' : '');
}).property('selected', 'name'),
}.property('selected', 'name'),
changed: (function() {
changed: function() {
var _this = this;
if (!this.originals) {
return false;
}
if (!this.originals) return false;
return this.trackedProperties.any(function(p) {
return _this.originals[p] !== _this.get(p);
});
}).property('override_default_style', 'enabled', 'name', 'stylesheet', 'header', 'originals'),
}.property('override_default_style', 'enabled', 'name', 'stylesheet', 'header', 'originals'),
startTrackingChanges: function() {
var _this = this;
@@ -37,18 +35,17 @@ Discourse.SiteCustomization = Discourse.Model.extend({
});
},
previewUrl: (function() {
previewUrl: function() {
return "/?preview-style=" + (this.get('key'));
}).property('key'),
}.property('key'),
disableSave: (function() {
disableSave: function() {
return !this.get('changed');
}).property('changed'),
}.property('changed'),
save: function() {
var data;
this.startTrackingChanges();
data = {
var data = {
name: this.name,
enabled: this.enabled,
stylesheet: this.stylesheet,
@@ -66,7 +63,6 @@ Discourse.SiteCustomization = Discourse.Model.extend({
destroy: function() {
if (!this.id) return;
return Discourse.ajax({
url: Discourse.getURL("/admin/site_customizations/") + this.id,
type: 'DELETE'
@@ -76,13 +72,12 @@ Discourse.SiteCustomization = Discourse.Model.extend({
});
var SiteCustomizations = Ember.ArrayProxy.extend({
selectedItemChanged: (function() {
var selected;
selected = this.get('selectedItem');
selectedItemChanged: function() {
var selected = this.get('selectedItem');
return this.get('content').each(function(i) {
return i.set('selected', selected === i);
});
}).observes('selectedItem')
}.observes('selectedItem')
});
Discourse.SiteCustomization.reopenClass({

View File

@@ -8,6 +8,12 @@ Discourse.Route.buildRoutes(function() {
this.resource('admin', { path: '/admin' }, function() {
this.route('dashboard', { path: '/' });
this.route('site_settings', { path: '/site_settings' });
this.resource('adminSiteContents', { path: '/site_contents' }, function() {
this.resource('adminSiteContentEdit', {path: '/:content_type'});
});
this.route('email_logs', { path: '/email_logs' });
this.route('customize', { path: '/customize' });
this.route('api', {path: '/api'});

View File

@@ -0,0 +1,39 @@
/**
Allows users to customize site content
@class AdminSiteContentEditRoute
@extends Discourse.Route
@namespace Discourse
@module Discourse
**/
Discourse.AdminSiteContentEditRoute = Discourse.Route.extend({
serialize: function(model) {
return {content_type: model.get('content_type')};
},
model: function(params) {
return {content_type: params.content_type};
},
renderTemplate: function() {
this.render('admin/templates/site_content_edit', {into: 'admin/templates/site_contents'});
},
exit: function() {
this._super();
this.render('admin/templates/site_contents_empty', {into: 'admin/templates/site_contents'});
},
setupController: function(controller, model) {
controller.set('loaded', false);
controller.setProperties({saving: false, saved: false});
Discourse.SiteContent.find(Em.get(model, 'content_type')).then(function (sc) {
controller.set('content', sc);
controller.set('loaded', true);
})
}
});

View File

@@ -0,0 +1,20 @@
/**
Allows users to customize site content
@class AdminSiteContentsRoute
@extends Discourse.Route
@namespace Discourse
@module Discourse
**/
Discourse.AdminSiteContentsRoute = Discourse.Route.extend({
model: function() {
return Discourse.SiteContentType.findAll();
},
renderTemplate: function() {
this.render('admin/templates/site_contents', {into: 'admin/templates/admin'});
this.render('admin/templates/site_contents_empty', {into: 'admin/templates/site_contents'});
}
});

View File

@@ -1,10 +1,11 @@
<div class="container">
<div class="row">
<div class="full-width">
<div class="full-width">
<ul class="nav nav-pills">
<li>{{#linkTo 'admin.dashboard'}}{{i18n admin.dashboard.title}}{{/linkTo}}</li>
<li>{{#linkTo 'admin.site_settings'}}{{i18n admin.site_settings.title}}{{/linkTo}}</li>
<li>{{#linkTo 'adminSiteContents'}}{{i18n admin.site_content.title}}{{/linkTo}}</li>
<li>{{#linkTo 'adminUsersList.active'}}{{i18n admin.users.title}}{{/linkTo}}</li>
<li>{{#linkTo 'admin.email_logs'}}{{i18n admin.email_logs.title}}{{/linkTo}}</li>
<li>{{#linkTo 'adminFlags.active'}}{{i18n admin.flags.title}}{{/linkTo}}</li>

View File

@@ -0,0 +1,36 @@
{{#if loaded}}
<h3>{{title}}</h3>
<p class='description'>{{description}}</p>
{{#if markdown}}
{{view Discourse.PagedownEditor valueBinding="content.content"}}
{{/if}}
{{#if plainText}}
{{view Ember.TextArea valueBinding="content.content" class="plain"}}
{{/if}}
{{#if html}}
{{view Discourse.AceEditorView contentBinding="content.content" mode="html"}}
{{/if}}
{{#if css}}
{{view Discourse.AceEditorView contentBinding="content.content" mode="css"}}
{{/if}}
<div class='controls'>
<button class='btn' {{action saveChanges}} {{bindAttr disabled="saveDisabled"}}>
{{#if saving}}
{{i18n saving}}
{{else}}
{{i18n save}}
{{/if}}
</button>
{{#if saved}}{{i18n saved}}{{/if}}
</div>
{{else}}
<div class='spinner'>{{i18n loading}}</div>
{{/if}}

View File

@@ -0,0 +1,16 @@
<div class='row'>
<div class='content-list span6'>
<h3>{{i18n admin.site_content.edit}}</h3>
<ul>
{{#each type in content}}
<li>
{{#linkTo 'adminSiteContentEdit' type}}{{type.title}}{{/linkTo}}
</li>
{{/each}}
</ul>
</div>
<div class='content-editor span15'>
{{outlet}}
</div>
</div>

View File

@@ -0,0 +1 @@
<p>{{i18n admin.site_content.none}}</p>

View File

@@ -36,6 +36,7 @@ Discourse.AceEditorView = Discourse.View.extend({
didInsertElement: function() {
var initAce,
_this = this;
initAce = function() {
_this.editor = ace.edit(_this.$('.ace')[0]);
_this.editor.setTheme("ace/theme/chrome");

View File

@@ -134,6 +134,10 @@ Discourse = Ember.Application.createWithMixins({
if (href === '#') return;
if ($currentTarget.attr('target')) return;
if ($currentTarget.data('auto-route')) return;
// If it's an ember #linkTo skip it
if ($currentTarget.hasClass('ember-view')) return;
if ($currentTarget.hasClass('lightbox')) return;
if (href.indexOf("mailto:") === 0) return;
if (href.match(/^http[s]?:\/\//i) && !href.match(new RegExp("^http:\\/\\/" + window.location.hostname, "i"))) return;

View File

@@ -11,6 +11,7 @@ Discourse.PreferencesEmailController = Discourse.ObjectController.extend({
saving: false,
error: false,
success: false,
newEmail: null,
saveDisabled: (function() {
if (this.get('saving')) return true;

View File

@@ -73,8 +73,7 @@ Ember.Handlebars.registerBoundHelper('boundCategoryLink', function(category) {
@for Handlebars
**/
Handlebars.registerHelper('titledLinkTo', function(name, object) {
var options;
options = [].slice.call(arguments, -1)[0];
var options = [].slice.call(arguments, -1)[0];
if (options.hash.titleKey) {
options.hash.title = Em.String.i18n(options.hash.titleKey);
}

View File

@@ -345,12 +345,12 @@ Discourse.User = Discourse.Model.extend({
}).property('stats.@each'),
/**
Number of items this user has sent.
Number of items this user has sent.
@property sentItemsCount
@type {Integer}
**/
sentItemsCount: (function() {
sentItemsCount: function() {
var r;
r = 0;
this.get('stats').each(function(s) {
@@ -360,7 +360,42 @@ Discourse.User = Discourse.Model.extend({
}
});
return r;
}).property('stats.@each')
}.property('stats.@each'),
/**
Load extra details for the user
@method loadDetails
**/
loadDetails: function() {
// Check the preload store first
var user = this;
var username = this.get('username');
PreloadStore.getAndRemove("user_" + username, function() {
return Discourse.ajax({ url: Discourse.getURL("/users/") + username + '.json' });
}).then(function (json) {
// Create a user from the resulting JSON
json.user.stats = Discourse.User.groupStats(json.user.stats.map(function(s) {
var stat = Em.Object.create(s);
stat.set('isPM', stat.get('action_type') === Discourse.UserAction.NEW_PRIVATE_MESSAGE ||
stat.get('action_type') === Discourse.UserAction.GOT_PRIVATE_MESSAGE);
return stat;
}));
var count = 0;
if (json.user.stream) {
count = json.user.stream.length;
json.user.stream = Discourse.UserAction.collapseStream(json.user.stream.map(function(ua) {
return Discourse.UserAction.create(ua);
}));
}
user.setProperties(json.user);
user.set('totalItems', count);
});
}
});
Discourse.User.reopenClass({
@@ -427,42 +462,6 @@ Discourse.User.reopenClass({
});
},
/**
Finds a user based on a username
@method find
@param {String} username The username
@returns a promise that will resolve to the user
**/
find: function(username) {
// Check the preload store first
return PreloadStore.getAndRemove("user_" + username, function() {
return Discourse.ajax({ url: Discourse.getURL("/users/") + username + '.json' });
}).then(function (json) {
// Create a user from the resulting JSON
json.user.stats = Discourse.User.groupStats(json.user.stats.map(function(s) {
var stat = Em.Object.create(s);
stat.set('isPM', stat.get('action_type') === Discourse.UserAction.NEW_PRIVATE_MESSAGE ||
stat.get('action_type') === Discourse.UserAction.GOT_PRIVATE_MESSAGE);
return stat;
}));
var count = 0;
if (json.user.stream) {
count = json.user.stream.length;
json.user.stream = Discourse.UserAction.collapseStream(json.user.stream.map(function(ua) {
return Discourse.UserAction.create(ua);
}));
}
var user = Discourse.User.create(json.user);
user.set('totalItems', count);
return user;
});
},
/**
Creates a new account over POST

View File

@@ -12,6 +12,16 @@ Discourse.PreferencesEmailRoute = Discourse.RestrictedUserRoute.extend({
this.render({ into: 'user', outlet: 'userOutlet' });
},
// A bit odd, but if we leave to /preferences we need to re-render that outlet
exit: function() {
this._super();
this.render('preferences', {
into: 'user',
outlet: 'userOutlet',
controller: 'preferences'
});
},
setupController: function(controller) {
controller.set('content', this.controllerFor('user').get('content'));
}

View File

@@ -17,6 +17,7 @@ Discourse.PreferencesRoute = Discourse.RestrictedUserRoute.extend({
},
setupController: function(controller) {
console.log('prefereces');
controller.set('content', this.controllerFor('user').get('content'));
}

View File

@@ -12,6 +12,16 @@ Discourse.PreferencesUsernameRoute = Discourse.RestrictedUserRoute.extend({
return this.render({ into: 'user', outlet: 'userOutlet' });
},
// A bit odd, but if we leave to /preferences we need to re-render that outlet
exit: function() {
this._super();
this.render('preferences', {
into: 'user',
outlet: 'userOutlet',
controller: 'preferences'
});
},
setupController: function(controller) {
var user = this.controllerFor('user').get('content');
controller.set('content', user);

View File

@@ -8,10 +8,15 @@
**/
Discourse.UserRoute = Discourse.Route.extend({
model: function(params) {
return Discourse.User.find(params.username);
return Discourse.User.create({username: params.username});
},
serialize: function(params) {
return { username: Em.get(params, 'username').toLowerCase() };
},
setupController: function(controller, model) {
model.loadDetails();
}
});

View File

@@ -101,7 +101,7 @@
<label>{{i18n user.new_topic_duration.label}}</label>
{{view Discourse.ComboboxView valueAttribute="value" contentBinding="controller.considerNewTopicOptions" valueBinding="content.new_topic_duration_minutes"}}
</div>
<div class="controls">
<label>{{view Ember.Checkbox checkedBinding="content.external_links_in_new_tab"}}
{{i18n user.external_links_in_new_tab}}</label>

View File

@@ -565,4 +565,78 @@ table {
::-webkit-scrollbar-track {
border-left: solid 1px #ddd;
}
}
}
.content-list {
h3 {
color: $darkish_gray;
font-size: 15px;
padding-left: 5px;
}
ul {
list-style: none;
margin: 0;
li {
border-bottom: 1px solid #ddd;
}
li a {
display: block;
padding: 10px;
color: $dark_gray;
&:hover {
background-color: #eee;
color: $dark_gray;
}
&.active {
font-weight: bold;
color: $black;
}
}
}
}
.content-editor {
min-height: 500px;
p.description {
color: $dark_gray;
}
.controls {
margin-top: 10px;
}
#pagedown-editor {
width: 98%;
}
textarea.plain {
width: 98%;
height: 200px;
}
#wmd-input {
width: 98%;
height: 200px;
}
.ace-wrapper {
position: relative;
height: 600px;
width: 100%;
}
.ace_editor {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
}
}

View File

@@ -0,0 +1,7 @@
class Admin::SiteContentTypesController < Admin::AdminController
def index
render_serialized(SiteContent.content_types, SiteContentTypeSerializer)
end
end

View File

@@ -0,0 +1,15 @@
class Admin::SiteContentsController < Admin::AdminController
def show
site_content = SiteContent.find_or_new(params[:id].to_s)
render_serialized(site_content, SiteContentSerializer)
end
def update
site_content = SiteContent.find_or_new(params[:id].to_s)
site_content.content = params[:content]
site_content.save!
render nothing: true
end
end

View File

@@ -25,6 +25,7 @@ class Admin::UsersController < Admin::AdminController
@user.delete_all_posts!(guardian)
render nothing: true
end
def ban
@user = User.where(id: params[:user_id]).first
guardian.ensure_can_ban!(@user)

View File

@@ -0,0 +1,24 @@
require_dependency 'site_content_type'
require_dependency 'site_content_class_methods'
class SiteContent < ActiveRecord::Base
extend SiteContentClassMethods
set_primary_key :content_type
validates_presence_of :content
def self.formats
@formats ||= Enum.new(:plain, :markdown, :html, :css)
end
content_type :usage_tips, :markdown, default_18n_key: 'system_messages.usage_tips.text_body_template'
content_type :welcome_user, :markdown, default_18n_key: 'system_messages.welcome_user.text_body_template'
content_type :welcome_invite, :markdown, default_18n_key: 'system_messages.welcome_invite.text_body_template'
content_type :education_new_topic, :markdown, default_18n_key: 'education.new-topic'
content_type :education_new_reply, :markdown, default_18n_key: 'education.new-reply'
def site_content_type
@site_content_type ||= SiteContent.content_types.find {|t| t.content_type == content_type.to_sym}
end
end

View File

@@ -0,0 +1,28 @@
require_dependency 'multisite_i18n'
class SiteContentType
attr_accessor :content_type, :format
def initialize(content_type, format, opts=nil)
@opts = opts || {}
@content_type = content_type
@format = format
end
def title
I18n.t("content_types.#{content_type}.title")
end
def description
I18n.t("content_types.#{content_type}.description")
end
def default_content
if @opts[:default_18n_key].present?
return MultisiteI18n.t(@opts[:default_18n_key])
end
""
end
end

View File

@@ -0,0 +1,25 @@
class SiteContentSerializer < ApplicationSerializer
attributes :content_type,
:title,
:description,
:content,
:format
def title
object.site_content_type.title
end
def description
object.site_content_type.description
end
def format
object.site_content_type.format
end
def content
return object.content if object.content.present?
object.site_content_type.default_content
end
end

View File

@@ -0,0 +1,13 @@
class SiteContentTypeSerializer < ApplicationSerializer
attributes :content_type, :title
def content_type
object.content_type
end
def title
object.title
end
end