From fa1ba6791bcdad003783e7aa483183db366f613b Mon Sep 17 00:00:00 2001
From: Robin Ward <robin.ward@gmail.com>
Date: Thu, 4 Apr 2013 12:59:44 -0400
Subject: [PATCH] Work in Progress: Content Editing in Admin Section

---
 .../admin_site_content_edit_controller.js     | 25 ++++++
 .../javascripts/admin/models/site_content.js  | 39 ++++++++++
 .../admin/models/site_content_type.js         | 21 +++++
 .../admin/models/site_customization.js        | 31 ++++----
 .../javascripts/admin/routes/admin_routes.js  |  6 ++
 .../routes/admin_site_content_edit_route.js   | 39 ++++++++++
 .../admin/routes/admin_site_contents_route.js | 20 +++++
 .../admin/templates/admin.js.handlebars       |  5 +-
 .../templates/site_content_edit.js.handlebars | 36 +++++++++
 .../templates/site_contents.js.handlebars     | 16 ++++
 .../site_contents_empty.js.handlebars         |  1 +
 .../admin/views/ace_editor_view.js            |  1 +
 app/assets/javascripts/discourse.js           |  4 +
 .../preferences_email_controller.js           |  1 +
 .../discourse/helpers/application_helpers.js  |  3 +-
 .../javascripts/discourse/models/user.js      | 77 +++++++++----------
 .../routes/preferences_email_route.js         | 10 +++
 .../discourse/routes/preferences_route.js     |  1 +
 .../routes/preferences_username_route.js      | 10 +++
 .../discourse/routes/user_route.js            |  7 +-
 .../templates/user/preferences.js.handlebars  |  2 +-
 app/assets/stylesheets/admin/admin_base.scss  | 76 +++++++++++++++++-
 .../admin/site_content_types_controller.rb    |  7 ++
 .../admin/site_contents_controller.rb         | 15 ++++
 app/controllers/admin/users_controller.rb     |  1 +
 app/models/site_content.rb                    | 24 ++++++
 app/models/site_content_type.rb               | 28 +++++++
 app/serializers/site_content_serializer.rb    | 25 ++++++
 .../site_content_type_serializer.rb           | 13 ++++
 config/locales/client.en.yml                  |  5 ++
 config/locales/server.en.yml                  | 17 ++++
 config/routes.rb                              |  2 +
 db/fixtures/site_content_types.rb             |  0
 .../20130404143437_create_site_contents.rb    | 10 +++
 lib/site_content_class_methods.rb             | 32 ++++++++
 .../site_content_types_controller_spec.rb     | 27 +++++++
 .../admin/site_contents_controller_spec.rb    | 29 +++++++
 spec/fabricators/site_content_fabricator.rb   |  9 +++
 spec/models/site_content_spec.rb              | 42 ++++++++++
 39 files changed, 653 insertions(+), 64 deletions(-)
 create mode 100644 app/assets/javascripts/admin/controllers/admin_site_content_edit_controller.js
 create mode 100644 app/assets/javascripts/admin/models/site_content.js
 create mode 100644 app/assets/javascripts/admin/models/site_content_type.js
 create mode 100644 app/assets/javascripts/admin/routes/admin_site_content_edit_route.js
 create mode 100644 app/assets/javascripts/admin/routes/admin_site_contents_route.js
 create mode 100644 app/assets/javascripts/admin/templates/site_content_edit.js.handlebars
 create mode 100644 app/assets/javascripts/admin/templates/site_contents.js.handlebars
 create mode 100644 app/assets/javascripts/admin/templates/site_contents_empty.js.handlebars
 create mode 100644 app/controllers/admin/site_content_types_controller.rb
 create mode 100644 app/controllers/admin/site_contents_controller.rb
 create mode 100644 app/models/site_content.rb
 create mode 100644 app/models/site_content_type.rb
 create mode 100644 app/serializers/site_content_serializer.rb
 create mode 100644 app/serializers/site_content_type_serializer.rb
 create mode 100644 db/fixtures/site_content_types.rb
 create mode 100644 db/migrate/20130404143437_create_site_contents.rb
 create mode 100644 lib/site_content_class_methods.rb
 create mode 100644 spec/controllers/admin/site_content_types_controller_spec.rb
 create mode 100644 spec/controllers/admin/site_contents_controller_spec.rb
 create mode 100644 spec/fabricators/site_content_fabricator.rb
 create mode 100644 spec/models/site_content_spec.rb

diff --git a/app/assets/javascripts/admin/controllers/admin_site_content_edit_controller.js b/app/assets/javascripts/admin/controllers/admin_site_content_edit_controller.js
new file mode 100644
index 000000000..e4a635eb5
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/admin_site_content_edit_controller.js
@@ -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});
+    });
+  }
+
+});
\ No newline at end of file
diff --git a/app/assets/javascripts/admin/models/site_content.js b/app/assets/javascripts/admin/models/site_content.js
new file mode 100644
index 000000000..03a49417b
--- /dev/null
+++ b/app/assets/javascripts/admin/models/site_content.js
@@ -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);
+    });
+  }
+
+});
\ No newline at end of file
diff --git a/app/assets/javascripts/admin/models/site_content_type.js b/app/assets/javascripts/admin/models/site_content_type.js
new file mode 100644
index 000000000..0b5570485
--- /dev/null
+++ b/app/assets/javascripts/admin/models/site_content_type.js
@@ -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;
+  }
+});
diff --git a/app/assets/javascripts/admin/models/site_customization.js b/app/assets/javascripts/admin/models/site_customization.js
index 35c78300e..4d5c366d5 100644
--- a/app/assets/javascripts/admin/models/site_customization.js
+++ b/app/assets/javascripts/admin/models/site_customization.js
@@ -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({
diff --git a/app/assets/javascripts/admin/routes/admin_routes.js b/app/assets/javascripts/admin/routes/admin_routes.js
index b82e89d64..4dc179dc3 100644
--- a/app/assets/javascripts/admin/routes/admin_routes.js
+++ b/app/assets/javascripts/admin/routes/admin_routes.js
@@ -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'});
diff --git a/app/assets/javascripts/admin/routes/admin_site_content_edit_route.js b/app/assets/javascripts/admin/routes/admin_site_content_edit_route.js
new file mode 100644
index 000000000..3c5103ddb
--- /dev/null
+++ b/app/assets/javascripts/admin/routes/admin_site_content_edit_route.js
@@ -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);
+    })
+  }
+
+
+});
diff --git a/app/assets/javascripts/admin/routes/admin_site_contents_route.js b/app/assets/javascripts/admin/routes/admin_site_contents_route.js
new file mode 100644
index 000000000..779f060cd
--- /dev/null
+++ b/app/assets/javascripts/admin/routes/admin_site_contents_route.js
@@ -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'});
+  }
+});
+
diff --git a/app/assets/javascripts/admin/templates/admin.js.handlebars b/app/assets/javascripts/admin/templates/admin.js.handlebars
index c52dfa662..8fa2bf43b 100644
--- a/app/assets/javascripts/admin/templates/admin.js.handlebars
+++ b/app/assets/javascripts/admin/templates/admin.js.handlebars
@@ -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>
diff --git a/app/assets/javascripts/admin/templates/site_content_edit.js.handlebars b/app/assets/javascripts/admin/templates/site_content_edit.js.handlebars
new file mode 100644
index 000000000..9955fa098
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/site_content_edit.js.handlebars
@@ -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}}
\ No newline at end of file
diff --git a/app/assets/javascripts/admin/templates/site_contents.js.handlebars b/app/assets/javascripts/admin/templates/site_contents.js.handlebars
new file mode 100644
index 000000000..4c694e60f
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/site_contents.js.handlebars
@@ -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>
\ No newline at end of file
diff --git a/app/assets/javascripts/admin/templates/site_contents_empty.js.handlebars b/app/assets/javascripts/admin/templates/site_contents_empty.js.handlebars
new file mode 100644
index 000000000..c37bd1e13
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/site_contents_empty.js.handlebars
@@ -0,0 +1 @@
+<p>{{i18n admin.site_content.none}}</p>
\ No newline at end of file
diff --git a/app/assets/javascripts/admin/views/ace_editor_view.js b/app/assets/javascripts/admin/views/ace_editor_view.js
index 1f9be90ef..b3b245142 100644
--- a/app/assets/javascripts/admin/views/ace_editor_view.js
+++ b/app/assets/javascripts/admin/views/ace_editor_view.js
@@ -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");
diff --git a/app/assets/javascripts/discourse.js b/app/assets/javascripts/discourse.js
index 5d6d748d9..a948420b8 100644
--- a/app/assets/javascripts/discourse.js
+++ b/app/assets/javascripts/discourse.js
@@ -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;
diff --git a/app/assets/javascripts/discourse/controllers/preferences_email_controller.js b/app/assets/javascripts/discourse/controllers/preferences_email_controller.js
index e6dda6c8a..f25b39ed5 100644
--- a/app/assets/javascripts/discourse/controllers/preferences_email_controller.js
+++ b/app/assets/javascripts/discourse/controllers/preferences_email_controller.js
@@ -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;
diff --git a/app/assets/javascripts/discourse/helpers/application_helpers.js b/app/assets/javascripts/discourse/helpers/application_helpers.js
index 866cdf5a1..422dae05a 100644
--- a/app/assets/javascripts/discourse/helpers/application_helpers.js
+++ b/app/assets/javascripts/discourse/helpers/application_helpers.js
@@ -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);
   }
diff --git a/app/assets/javascripts/discourse/models/user.js b/app/assets/javascripts/discourse/models/user.js
index 84e35e92a..1d0dc5fa4 100644
--- a/app/assets/javascripts/discourse/models/user.js
+++ b/app/assets/javascripts/discourse/models/user.js
@@ -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
 
diff --git a/app/assets/javascripts/discourse/routes/preferences_email_route.js b/app/assets/javascripts/discourse/routes/preferences_email_route.js
index 2324b67d2..ffffce960 100644
--- a/app/assets/javascripts/discourse/routes/preferences_email_route.js
+++ b/app/assets/javascripts/discourse/routes/preferences_email_route.js
@@ -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'));
   }
diff --git a/app/assets/javascripts/discourse/routes/preferences_route.js b/app/assets/javascripts/discourse/routes/preferences_route.js
index f1d5e6e52..d121fb488 100644
--- a/app/assets/javascripts/discourse/routes/preferences_route.js
+++ b/app/assets/javascripts/discourse/routes/preferences_route.js
@@ -17,6 +17,7 @@ Discourse.PreferencesRoute = Discourse.RestrictedUserRoute.extend({
   },
 
   setupController: function(controller) {
+    console.log('prefereces');
     controller.set('content', this.controllerFor('user').get('content'));
   }
 
diff --git a/app/assets/javascripts/discourse/routes/preferences_username_route.js b/app/assets/javascripts/discourse/routes/preferences_username_route.js
index 85322f53d..82d0ddce9 100644
--- a/app/assets/javascripts/discourse/routes/preferences_username_route.js
+++ b/app/assets/javascripts/discourse/routes/preferences_username_route.js
@@ -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);
diff --git a/app/assets/javascripts/discourse/routes/user_route.js b/app/assets/javascripts/discourse/routes/user_route.js
index 3e2e079f8..33da69294 100644
--- a/app/assets/javascripts/discourse/routes/user_route.js
+++ b/app/assets/javascripts/discourse/routes/user_route.js
@@ -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();
   }
+
 });
diff --git a/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars b/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars
index fa08a39ac..94a36dc02 100644
--- a/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars
+++ b/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars
@@ -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>
diff --git a/app/assets/stylesheets/admin/admin_base.scss b/app/assets/stylesheets/admin/admin_base.scss
index edc64e617..c34e10776 100644
--- a/app/assets/stylesheets/admin/admin_base.scss
+++ b/app/assets/stylesheets/admin/admin_base.scss
@@ -565,4 +565,78 @@ table {
   ::-webkit-scrollbar-track {
     border-left: solid 1px #ddd;
   }
-}
\ No newline at end of file
+}
+
+.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;
+  }
+
+}
diff --git a/app/controllers/admin/site_content_types_controller.rb b/app/controllers/admin/site_content_types_controller.rb
new file mode 100644
index 000000000..fd7eaf442
--- /dev/null
+++ b/app/controllers/admin/site_content_types_controller.rb
@@ -0,0 +1,7 @@
+class Admin::SiteContentTypesController < Admin::AdminController
+
+  def index
+    render_serialized(SiteContent.content_types, SiteContentTypeSerializer)
+  end
+
+end
diff --git a/app/controllers/admin/site_contents_controller.rb b/app/controllers/admin/site_contents_controller.rb
new file mode 100644
index 000000000..5d4d6124a
--- /dev/null
+++ b/app/controllers/admin/site_contents_controller.rb
@@ -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
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 1e020cd00..b78c16e68 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -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)
diff --git a/app/models/site_content.rb b/app/models/site_content.rb
new file mode 100644
index 000000000..5eea42d34
--- /dev/null
+++ b/app/models/site_content.rb
@@ -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
diff --git a/app/models/site_content_type.rb b/app/models/site_content_type.rb
new file mode 100644
index 000000000..32f42cbf9
--- /dev/null
+++ b/app/models/site_content_type.rb
@@ -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
\ No newline at end of file
diff --git a/app/serializers/site_content_serializer.rb b/app/serializers/site_content_serializer.rb
new file mode 100644
index 000000000..2c6dbf365
--- /dev/null
+++ b/app/serializers/site_content_serializer.rb
@@ -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
diff --git a/app/serializers/site_content_type_serializer.rb b/app/serializers/site_content_type_serializer.rb
new file mode 100644
index 000000000..da906dedb
--- /dev/null
+++ b/app/serializers/site_content_type_serializer.rb
@@ -0,0 +1,13 @@
+class SiteContentTypeSerializer < ApplicationSerializer
+
+  attributes :content_type, :title
+
+  def content_type
+    object.content_type
+  end
+
+  def title
+    object.title
+  end
+
+end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 6fb281a27..16166eee2 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -908,6 +908,11 @@ en:
         approved_by: "approved by"
         time_read: "Read Time"
 
+      site_content:
+        none: "Choose a type of content to begin editing."
+        title: 'Site Content'
+        edit: "Edit Site Content"
+
       site_settings:
         show_overriden: 'Only show overridden'
         title: 'Site Settings'
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 910af47e0..4705b80f3 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -310,6 +310,23 @@ en:
     twitter_config_warning: 'The server is configured to allow signup and log in with Twitter (enable_twitter_logins), but the key and secret values are not set. Go to <a href="/admin/site_settings">the Site Settings</a> and update the settings. <a href="https://github.com/discourse/discourse/wiki/The-Discourse-Admin-Quick-Start-Guide#enable-twitter-logins" target="_blank">See this guide to learn more</a>.'
     github_config_warning: 'The server is configured to allow signup and log in with GitHub (enable_github_logins), but the client id and secret values are not set. Go to <a href="/admin/site_settings">the Site Settings</a> and update the settings. <a href="https://github.com/discourse/discourse/wiki/The-Discourse-Admin-Quick-Start-Guide" target="_blank">See this guide to learn more</a>.'
 
+  content_types:
+    education_new_reply:
+      title: "New User Education: New Reply"
+      description: "The pop up text a user sees when replying."
+    education_new_topic:
+      title: "New User Education: New Topic"
+      description: "The pop up text a user sees when creating a topic."
+    usage_tips:
+      title: "Usage Tips"
+      description: "The usage tips a new user receives when welcomed to the forum."
+    welcome_user:
+      title: "Welcome: New User"
+      description: "The system message a new user receives."
+    welcome_invite:
+      title: "Welcome: Invited User"
+      description: "The system message an invited user receives."
+
   site_settings:
     default_locale: "The default language of this Discourse instance (ISO 639-1 Code)"
     min_post_length: "Minimum post length in characters"
diff --git a/config/routes.rb b/config/routes.rb
index 3fe0d6270..4139f2c3f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -54,6 +54,8 @@ Discourse::Application.routes.draw do
     get 'flags/:filter' => 'flags#index'
     post 'flags/clear/:id' => 'flags#clear'
     resources :site_customizations
+    resources :site_contents
+    resources :site_content_types
     resources :export
     get 'version_check' => 'versions#show'
     resources :dashboard, only: [:index] do
diff --git a/db/fixtures/site_content_types.rb b/db/fixtures/site_content_types.rb
new file mode 100644
index 000000000..e69de29bb
diff --git a/db/migrate/20130404143437_create_site_contents.rb b/db/migrate/20130404143437_create_site_contents.rb
new file mode 100644
index 000000000..c4554c911
--- /dev/null
+++ b/db/migrate/20130404143437_create_site_contents.rb
@@ -0,0 +1,10 @@
+class CreateSiteContents < ActiveRecord::Migration
+  def change
+    create_table :site_contents, force: true, id: false do |t|
+      t.string :content_type, null: false
+      t.text :content, null: false
+      t.timestamps
+    end
+    add_index :site_contents, :content_type, unique: true
+  end
+end
diff --git a/lib/site_content_class_methods.rb b/lib/site_content_class_methods.rb
new file mode 100644
index 000000000..7843b0c0b
--- /dev/null
+++ b/lib/site_content_class_methods.rb
@@ -0,0 +1,32 @@
+module SiteContentClassMethods
+
+  def content_types
+    @types || []
+  end
+
+  def content_type(content_type, format, opts=nil)
+    opts ||= {}
+    @types ||= []
+    @types << SiteContentType.new(content_type, format, opts)
+  end
+
+  def content_for(content_type, replacements=nil)
+    replacements ||= {}
+
+    site_content = SiteContent.select(:content).where(content_type: content_type).first
+    return "" if site_content.blank?
+
+    site_content.content % replacements
+  end
+
+
+  def find_or_new(content_type)
+    site_content = SiteContent.where(content_type: content_type).first
+    return site_content if site_content.present?
+
+    site_content = SiteContent.new
+    site_content.content_type = content_type
+    site_content
+  end
+
+end
diff --git a/spec/controllers/admin/site_content_types_controller_spec.rb b/spec/controllers/admin/site_content_types_controller_spec.rb
new file mode 100644
index 000000000..4538800eb
--- /dev/null
+++ b/spec/controllers/admin/site_content_types_controller_spec.rb
@@ -0,0 +1,27 @@
+require 'spec_helper'
+
+describe Admin::SiteContentTypesController do
+
+  it "is a subclass of AdminController" do
+    (Admin::SiteContentTypesController < Admin::AdminController).should be_true
+  end
+
+  context 'while logged in as an admin' do
+    before do
+      @user = log_in(:admin)
+    end
+
+    context ' .index' do
+      it 'returns success' do
+        xhr :get, :index
+        response.should be_success
+      end
+
+      it 'returns JSON' do
+        xhr :get, :index
+        ::JSON.parse(response.body).should be_present
+      end
+    end
+  end
+
+end
diff --git a/spec/controllers/admin/site_contents_controller_spec.rb b/spec/controllers/admin/site_contents_controller_spec.rb
new file mode 100644
index 000000000..11c49e0ed
--- /dev/null
+++ b/spec/controllers/admin/site_contents_controller_spec.rb
@@ -0,0 +1,29 @@
+require 'spec_helper'
+
+describe Admin::SiteContentsController do
+
+  it "is a subclass of AdminController" do
+    (Admin::SiteContentsController < Admin::AdminController).should be_true
+  end
+
+  context 'while logged in as an admin' do
+    before do
+      @user = log_in(:admin)
+    end
+
+    context '.show' do
+      let(:content_type) { SiteContent.content_types.first.content_type }
+
+      it 'returns success' do
+        xhr :get, :show, id: content_type
+        response.should be_success
+      end
+
+      it 'returns JSON' do
+        xhr :get, :show, id: content_type
+        ::JSON.parse(response.body).should be_present
+      end
+    end
+  end
+
+end
diff --git a/spec/fabricators/site_content_fabricator.rb b/spec/fabricators/site_content_fabricator.rb
new file mode 100644
index 000000000..56ce55c05
--- /dev/null
+++ b/spec/fabricators/site_content_fabricator.rb
@@ -0,0 +1,9 @@
+Fabricator(:site_content) do
+  content_type 'great.poem'
+  content "%{flower} are red. %{food} are blue."
+end
+
+Fabricator(:site_content_basic, from: :site_content) do
+  content_type 'breaking.bad'
+  content "best show ever"
+end
\ No newline at end of file
diff --git a/spec/models/site_content_spec.rb b/spec/models/site_content_spec.rb
new file mode 100644
index 000000000..ed8512d1e
--- /dev/null
+++ b/spec/models/site_content_spec.rb
@@ -0,0 +1,42 @@
+require 'spec_helper'
+
+describe SiteContent do
+
+  it { should validate_presence_of :content }
+
+
+  describe "#content_for" do
+
+    it "returns an empty string for a missing content_type" do
+      SiteContent.content_for('breaking.bad').should == ""
+    end
+
+    context "without replacements" do
+      let!(:site_content) { Fabricate(:site_content_basic) }
+
+      it "returns the simple string" do
+        SiteContent.content_for('breaking.bad').should == "best show ever"
+      end
+
+    end
+
+    context "with replacements" do
+      let!(:site_content) { Fabricate(:site_content) }
+      let(:replacements) { {flower: 'roses', food: 'grapes'} }
+
+      it "returns the correct string with replacements" do
+        SiteContent.content_for('great.poem', replacements).should == "roses are red. grapes are blue."
+      end
+
+      it "doesn't mind extra keys in the replacements" do
+        SiteContent.content_for('great.poem', replacements.merge(extra: 'key')).should == "roses are red. grapes are blue."
+      end
+
+      it "raises an error with missing keys" do
+        -> { SiteContent.content_for('great.poem', flower: 'roses') }.should raise_error
+      end
+    end
+
+  end
+
+end