diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000000000000000000000000000000000000..2c205e2260d3671dc2040d6871e51c1f6ab65da6
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,51 @@
+name: "CodeQL analysis"
+
+on:
+  push:
+  pull_request:
+  schedule:
+    - cron: '0 21 * * *'
+
+jobs:
+  CodeQL-Build:
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - name: Checkout repository
+      uses: actions/checkout@v2
+      with:
+        # We must fetch at least the immediate parents so that if this is
+        # a pull request then we can checkout the head.
+        fetch-depth: 2
+
+    # If this run was triggered by a pull request event, then checkout
+    # the head of the pull request instead of the merge commit.
+    - run: git checkout HEAD^2
+      if: ${{ github.event_name == 'pull_request' }}
+      
+    # Initializes the CodeQL tools for scanning.
+    - name: Initialize CodeQL
+      uses: github/codeql-action/init@v1
+      # Override language selection by uncommenting this and choosing your languages
+      with:
+        languages: javascript
+
+    # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).
+    # If this step fails, then you should remove it and run the build manually (see below)
+    - name: Autobuild
+      uses: github/codeql-action/autobuild@v1
+
+    # ℹ️ Command-line programs to run using the OS shell.
+    # 📚 https://git.io/JvXDl
+
+    # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
+    #    and modify them (or add more) to build your code if your project
+    #    uses a compiled language
+
+    #- run: |
+    #   make bootstrap
+    #   make release
+
+    - name: Perform CodeQL Analysis
+      uses: github/codeql-action/analyze@v1
diff --git a/.github/workflows/zap-analysis.yml b/.github/workflows/zap-analysis.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e7e66f58e467712ac9b3e5c0c150d1469b146d3f
--- /dev/null
+++ b/.github/workflows/zap-analysis.yml
@@ -0,0 +1,18 @@
+name: OWASP ZAP analysis
+on:
+  schedule:
+    - cron: '0 12 * * 1'
+
+jobs:
+  zap_scan:
+    runs-on: ubuntu-latest
+    name: OWASP ZAP scan
+    steps:
+      - name: ZAP Scan
+        uses: zaproxy/action-full-scan@v0.1.0
+        with:
+          token: ${{ secrets.GITHUB_TOKEN }}
+          docker_name: 'owasp/zap2docker-stable'
+          target: 'https://meta.codidact.com/'
+          rules_file_name: '.zap/rules.tsv'
+          cmd_options: '-a'
diff --git a/.gitignore b/.gitignore
index 54a2fd496c5c9ab704f0fed65e75cf67153547a1..fad9e645a7af0697c35aa25734b6681caf6742de 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,8 +31,6 @@ config/database.yml
 
 deploy
 
-/public/assets/community
-
 # Ignore master key for decrypting credentials and more.
 /config/master.key
 
diff --git a/.rubocop.yml b/.rubocop.yml
index ec25e2879a3ba78042335e7cab9a84485cc41d47..17c5676c64460cdb0e9954a8d5bb58a779b568eb 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -31,7 +31,7 @@ Metrics/AbcSize:
 Metrics/BlockLength:
   Max: 30
 Metrics/ClassLength:
-  Max: 200
+  Max: 300
 Metrics/CyclomaticComplexity:
   Max: 10
 Metrics/MethodLength:
diff --git a/Gemfile b/Gemfile
index 127995eeff2f5ebc6537cd889ddee6a94ea64901..5a2cbf287a4961de69eed34466666b3319613501 100644
--- a/Gemfile
+++ b/Gemfile
@@ -5,6 +5,7 @@ ruby '2.6.5'
 gem 'coffee-rails', '~> 4.2.2'
 gem 'counter_culture', '~> 2.0'
 gem 'devise', '~> 4.7'
+gem 'image_processing', '~> 1.11'
 gem 'jquery-rails', '~> 4.3.5'
 gem 'mysql2', '~> 0.5.3'
 gem 'puma', '~> 3.12.4'
diff --git a/Gemfile.lock b/Gemfile.lock
index b9b9011b43e187a02654b227f792d14ec84053cb..5842e8fea992eb904ae9fbcaf1500e038dbdb2f5 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -118,6 +118,9 @@ GEM
       activesupport (>= 5)
     i18n (1.8.2)
       concurrent-ruby (~> 1.0)
+    image_processing (1.11.0)
+      mini_magick (>= 4.9.5, < 5)
+      ruby-vips (>= 2.0.17, < 3)
     jbuilder (2.10.0)
       activesupport (>= 5.0.0)
     jmespath (1.4.0)
@@ -139,6 +142,7 @@ GEM
       mime-types-data (~> 3.2015)
     mime-types-data (3.2020.0512)
     mimemagic (0.3.5)
+    mini_magick (4.10.1)
     mini_mime (1.0.2)
     mini_portile2 (2.4.0)
     minitest (5.10.3)
@@ -157,7 +161,7 @@ GEM
       ast (~> 2.4.0)
     public_suffix (4.0.5)
     puma (3.12.6)
-    rack (2.2.2)
+    rack (2.2.3)
     rack-mini-profiler (2.0.2)
       rack (>= 1.2.0)
     rack-test (1.1.0)
@@ -221,6 +225,8 @@ GEM
     ruby-enum (0.8.0)
       i18n
     ruby-progressbar (1.10.1)
+    ruby-vips (2.0.17)
+      ffi (~> 1.9)
     sass (3.7.4)
       sass-listen (~> 4.0.0)
     sass-listen (4.0.0)
@@ -298,6 +304,7 @@ DEPENDENCIES
   e2mmap (~> 0.1)
   flamegraph (~> 0.9)
   groupdate (~> 4.3)
+  image_processing (~> 1.11)
   jbuilder (~> 2.10)
   jquery-rails (~> 4.3.5)
   memory_profiler (~> 0.9)
diff --git a/app/assets/images/down-clear.png b/app/assets/images/down-clear.png
deleted file mode 100644
index b7a0b78c394716c7a40a59932220a12c2cafdf48..0000000000000000000000000000000000000000
Binary files a/app/assets/images/down-clear.png and /dev/null differ
diff --git a/app/assets/images/down-fill.png b/app/assets/images/down-fill.png
deleted file mode 100644
index be454f00e7c1f8fddccfb0323ae91ad0f767d2ee..0000000000000000000000000000000000000000
Binary files a/app/assets/images/down-fill.png and /dev/null differ
diff --git a/app/assets/images/favicon.png b/app/assets/images/favicon.png
deleted file mode 100644
index d9584d400ebfbf4b9a490fd98b6b4f202c49ba65..0000000000000000000000000000000000000000
Binary files a/app/assets/images/favicon.png and /dev/null differ
diff --git a/app/assets/images/fbm.ico b/app/assets/images/fbm.ico
new file mode 100644
index 0000000000000000000000000000000000000000..6cb1da55bdfc2cc61e451d4861ae670ab1cb7094
Binary files /dev/null and b/app/assets/images/fbm.ico differ
diff --git a/app/assets/images/judaism.png b/app/assets/images/judaism.png
new file mode 100644
index 0000000000000000000000000000000000000000..b4ae28f3da43b6761861103fc28926ea6950a76f
Binary files /dev/null and b/app/assets/images/judaism.png differ
diff --git a/app/assets/images/logo.png b/app/assets/images/logo.png
index 9936db4be6d9b7ebee15262d5c08d0d41840a170..7838db6205bb8f1d5687ea74903faad0fe82c697 100644
Binary files a/app/assets/images/logo.png and b/app/assets/images/logo.png differ
diff --git a/app/assets/images/meta-icon.ico b/app/assets/images/meta-icon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..33103af79e278bd6f6d0ef6c17e35cb7bc742b1a
Binary files /dev/null and b/app/assets/images/meta-icon.ico differ
diff --git a/app/assets/images/meta.png b/app/assets/images/meta.png
new file mode 100644
index 0000000000000000000000000000000000000000..8bac211cddf4027f7298fd5d83355a33490ac451
Binary files /dev/null and b/app/assets/images/meta.png differ
diff --git a/app/assets/images/outdoors-favicon.png b/app/assets/images/outdoors-favicon.png
deleted file mode 100755
index ceb42802bd5580926e42b21b096f41f78485b5f5..0000000000000000000000000000000000000000
Binary files a/app/assets/images/outdoors-favicon.png and /dev/null differ
diff --git a/app/assets/images/up-clear.png b/app/assets/images/up-clear.png
deleted file mode 100644
index a489a47690a7494aaf91754177513fe96a89da34..0000000000000000000000000000000000000000
Binary files a/app/assets/images/up-clear.png and /dev/null differ
diff --git a/app/assets/images/up-fill.png b/app/assets/images/up-fill.png
deleted file mode 100644
index 4d785adbfd42151447408118f9645faa873510e8..0000000000000000000000000000000000000000
Binary files a/app/assets/images/up-fill.png and /dev/null differ
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index c0465b547126f93bb174789b19514983529e770e..482628b5b2f86720c4a8fe66da5b69ff86c18c08 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -15,46 +15,6 @@
 //= require jquery_ujs
 //= require_tree .
 
-window.QPixel = {
-  csrfToken: () => {
-    const token = $('meta[name="csrf-token"]').attr('content');
-    QPixel.csrfToken = () => token;
-    return token;
-  },
-
-  createNotification: function(type, message, relativeElement) {
-    $("<div></div>")
-      .addClass("notice has-shadow-3 is-" + type)
-      .html('<button type="button" class="button is-close-button" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button><p>' + message+"</p>")
-      .css({
-        'position': 'fixed',
-        'top': "50px",
-        'left': "50%",
-        'transform': "translateX(-50%)",
-        'z-index': 100,
-        'width': '100%',
-        'max-width': "800px",
-        'cursor': 'pointer'
-      })
-      .on('click', function(ev) {
-        $(this).fadeOut(200, function() {
-          $(this).remove();
-        });
-      })
-      .appendTo(document.body);
-  },
-
-  offset: function(el) {
-    const topLeft = $(el).offset();
-    return {
-      top: topLeft.top,
-      left: topLeft.left,
-      bottom: topLeft.top + $(el).outerHeight(),
-      right: topLeft.left + $(el).outerWidth()
-    };
-  }
-};
-
 $(document).on('ready', function() {
   $("a.flag-dialog-link").bind("click", (ev) => {
     ev.preventDefault();
diff --git a/app/assets/javascripts/character_count.js b/app/assets/javascripts/character_count.js
index 2871c23cd4439d42647d2835d24e24968d705b1e..d8486f03c0fdcef867284fef1b6bb440c80243e6 100644
--- a/app/assets/javascripts/character_count.js
+++ b/app/assets/javascripts/character_count.js
@@ -4,7 +4,7 @@ $(() => {
     const $target = $el.siblings($el.attr('data-target'));
     const max = $el.attr('data-max');
 
-    $target.on('keyup', (ev) => {
+    $target.on('keyup cc-reset', (ev) => {
       const $tgt = $(ev.target);
       const count = $tgt.val().length;
       const text = `${count} / ${max}`;
@@ -31,5 +31,9 @@ $(() => {
       }
       $el.text(text);
     });
+
+    $target.parents('form').on('ajax:success', ev => {
+      $target.val('').trigger('cc-reset');
+    });
   });
 });
\ No newline at end of file
diff --git a/app/assets/javascripts/comments.js b/app/assets/javascripts/comments.js
index d5ef63f1697debd6291d884f1fde588eeeae658a..2ba23a74bdffb25d6332298f9591ce3322db30e3 100644
--- a/app/assets/javascripts/comments.js
+++ b/app/assets/javascripts/comments.js
@@ -4,7 +4,9 @@ $(() => {
   $('.js-add-comment').on('click', async evt => {
     evt.preventDefault();
 
-    $(evt.target).parent().find('.js-comment-form').show();
+    const $form = $(evt.target).parent().find('.js-comment-form');
+    $form.show();
+    $form.find('.js-comment-content').focus();
   });
 
   $('.js-more-comments').on('click', async evt => {
diff --git a/app/assets/javascripts/markdown.js b/app/assets/javascripts/markdown.js
index a9d382485d1ccc7ede7f043f0b5584b831c3ef19..dad5472f2563ce17469457845c56c5758e7f0d62 100644
--- a/app/assets/javascripts/markdown.js
+++ b/app/assets/javascripts/markdown.js
@@ -10,6 +10,11 @@ $(() => {
     $field.val(value).trigger('markdown');
   };
 
+  const replaceSelection = ($field, text) => {
+    const prev = $field.val();
+    $field.val(prev.substring(0, $field[0].selectionStart) + text + prev.substring($field[0].selectionEnd));
+  };
+
   $(document).on('click', '.js-markdown-tool', ev => {
     const $tgt = $(ev.target);
     const $button = $tgt.is('a') ? $tgt : $tgt.parents('a');
@@ -28,18 +33,39 @@ $(() => {
     };
 
     if (Object.keys(actions).indexOf(action) !== -1) {
+      const preSelection = [$field[0].selectionStart, $field[0].selectionEnd];
       insertIntoField($field, actions[action][0], actions[action][1]);
       $field.focus();
+      $field[0].selectionStart = preSelection[0] + actions[action][0].length;
+      $field[0].selectionEnd = preSelection[1] + actions[action][0].length;
     }
   });
 
   $(document).on('click', '.js-markdown-insert-link', ev => {
     ev.preventDefault();
     const $tgt = $(ev.target);
-    const text = $('#markdown-link-name').val();
-    const url = $('#markdown-link-url').val();
+    const $name = $('#markdown-link-name');
+    const text = $name.val();
+    const $url = $('#markdown-link-url');
+    const url = $url.val();
     const markdown = `[${text}](${url})`;
-    insertIntoField($('.js-post-field'), markdown);
+    const $field = $('.js-post-field');
+    if ($field[0].selectionStart != null && $field[0].selectionStart !== $field[0].selectionEnd) {
+      replaceSelection($field, markdown);
+    }
+    else {
+      insertIntoField($field, markdown);
+    }
     $tgt.parents('.modal').removeClass('is-active');
+    $name.val('');
+    $url.val('');
+  });
+
+  $(document).on('click', '[data-modal="#markdown-link-insert"]', ev => {
+    const $field = $('.js-post-field');
+    const selection = $field.val().substring($field[0].selectionStart, $field[0].selectionEnd);
+    if (selection) {
+      $('#markdown-link-name').val(selection);
+    }
   });
 });
\ No newline at end of file
diff --git a/app/assets/javascripts/modals.js b/app/assets/javascripts/modals.js
index 877a68b36b4438bfd44b124a66b2c6a90e2027a3..f915616c9c39ed2c65d362e7bcfef7b31982542a 100644
--- a/app/assets/javascripts/modals.js
+++ b/app/assets/javascripts/modals.js
@@ -1,7 +1,8 @@
 $(() => {
   $(document).on('click', '[data-modal]', ev => {
     const $tgt = $(ev.target);
-    const $modal = $($tgt.attr('data-modal'));
+    const $trigger = $tgt.is('[data-modal]') ? $tgt : $tgt.parents('[data-modal]');
+    const $modal = $($trigger.attr('data-modal'));
     $modal.toggleClass('is-active');
   });
 
diff --git a/app/assets/javascripts/posts.js b/app/assets/javascripts/posts.js
index ae64cf2ce8cfb68b5b67c4d2bed25ca5a98ea4f3..fc8f384e64c9751846ba870be8809e58cbf41292 100644
--- a/app/assets/javascripts/posts.js
+++ b/app/assets/javascripts/posts.js
@@ -26,7 +26,12 @@ $(() => {
       body: new FormData($tgt[0])
     });
     const data = await resp.json();
-    $tgt.trigger('ajax:success', data);
+    if (resp.status === 200) {
+      $tgt.trigger('ajax:success', data);
+    }
+    else {
+      $tgt.trigger('ajax:failure', data);
+    }
   });
 
   $uploadForm.on('ajax:success', async (evt, data) => {
@@ -39,13 +44,45 @@ $(() => {
     $tgt.parents('.modal').removeClass('is-active');
   });
 
+  $uploadForm.on('ajax:failure', async (evt, data) => {
+    const $tgt = $(evt.target);
+    const $postField = $('.js-post-field');
+    const error = data['error'];
+    QPixel.createNotification('danger', error, $tgt);
+    $tgt.parents('.modal').removeClass('is-active');
+    $postField.val($postField.val().replace(placeholder, ''));
+  });
+
   $('.js-category-select').select2({
     tags: true
   });
 
+  const saveDraft = async (postText, $field) => {
+    const resp = await fetch('/posts/save-draft', {
+      method: 'POST',
+      credentials: 'include',
+      headers: {
+        'X-CSRF-Token': QPixel.csrfToken(),
+        'Content-Type': 'application/json'
+      },
+      body: JSON.stringify({
+        post: postText,
+        path: location.pathname
+      })
+    });
+    if (resp.status === 200) {
+      const $el = $(`<span class="has-color-green-600">Draft saved</span>`);
+      $field.parents('.widget').after($el);
+      $el.fadeOut(1500, function () { $(this).remove() });
+    }
+  };
+
   let mathjaxTimeout = null;
+  let draftTimeout = null;
 
-  $('.post-field').on('keyup markdown', evt => {
+  const postFields = $('.post-field');
+
+  postFields.on('focus keyup markdown', evt => {
     if (!window.converter) {
       window.converter = window.markdownit({
         html: true,
@@ -66,7 +103,44 @@ $(() => {
     }
 
     mathjaxTimeout = setTimeout(() => {
-      MathJax.typeset();
+      if (window['MathJax']) {
+        MathJax.typeset();
+      }
     }, 1000);
+  }).on('keyup', ev => {
+    clearTimeout(draftTimeout);
+    const text = $(ev.target).val();
+    draftTimeout = setTimeout(() => {
+      saveDraft(text, $(ev.target));
+    }, 3000);
+  }).trigger('markdown');
+
+  postFields.parents('form').on('submit', async ev => {
+    const $tgt = $(ev.target);
+    if ($tgt.attr('data-draft-deleted') !== 'true') {
+      ev.preventDefault();
+      const resp = await fetch('/posts/delete-draft', {
+        method: 'POST',
+        credentials: 'include',
+        headers: {
+          'X-CSRF-Token': QPixel.csrfToken(),
+          'Content-Type': 'application/json'
+        },
+        body: JSON.stringify({ path: location.pathname })
+      });
+      if (resp.status === 200) {
+        $tgt.attr('data-draft-deleted', 'true').submit();
+      }
+      else {
+        console.error('Failed to delete draft.');
+      }
+    }
+  });
+
+  $('.js-draft-loaded').each((i, e) => {
+    $(e).parents('.widget').after(`<div class="notice is-info has-font-size-caption">
+      <i class="fas fa-exclamation-circle"></i> <strong>Draft loaded.</strong>
+      You've edited this post before but didn't save it. We loaded your edits here for you.
+    </div>`);
   });
 });
\ No newline at end of file
diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js
new file mode 100644
index 0000000000000000000000000000000000000000..2f20b858847d07d67ebf80deeb1ece8101ca7d39
--- /dev/null
+++ b/app/assets/javascripts/qpixel_api.js
@@ -0,0 +1,60 @@
+window.QPixel = {
+  csrfToken: () => {
+    const token = $('meta[name="csrf-token"]').attr('content');
+    QPixel.csrfToken = () => token;
+    return token;
+  },
+
+  createNotification: function(type, message, relativeElement) {
+    $("<div></div>")
+    .addClass("notice has-shadow-3 is-" + type)
+    .html('<button type="button" class="button is-close-button" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button><p>' + message+"</p>")
+    .css({
+      'position': 'fixed',
+      'top': "50px",
+      'left': "50%",
+      'transform': "translateX(-50%)",
+      'z-index': 100,
+      'width': '100%',
+      'max-width': "800px",
+      'cursor': 'pointer'
+    })
+    .on('click', function(ev) {
+      $(this).fadeOut(200, function() {
+        $(this).remove();
+      });
+    })
+    .appendTo(document.body);
+  },
+
+  offset: function(el) {
+    const topLeft = $(el).offset();
+    return {
+      top: topLeft.top,
+      left: topLeft.left,
+      bottom: topLeft.top + $(el).outerHeight(),
+      right: topLeft.left + $(el).outerWidth()
+    };
+  },
+
+  addEditorButton: function ($buttonHtml, shortName, callback) {
+    const html = `<a href="javascript:void(0)" class="button is-muted is-outlined" title="${shortName}"
+                     aria-label="${shortName}"></a>`;
+    const $button = $(html).html($buttonHtml);
+
+    const insertButton = () => {
+      $('.js-markdown-tools').each((i, e) => {
+        const $tgt = $(e);
+        let $customGroup = $tgt.find('.button-list.js-custom-tools');
+        if ($customGroup.length === 0) {
+          $customGroup = $(`<div class="button-list is-gutterless js-custom-tools"></div>`);
+          $customGroup.appendTo($tgt);
+        }
+
+        $button.clone().on('click', callback).appendTo($customGroup);
+      });
+    };
+
+    insertButton();
+  }
+};
\ No newline at end of file
diff --git a/app/assets/javascripts/subscriptions.js b/app/assets/javascripts/subscriptions.js
index 1787358a0b45bd0ad5db1d55c8609509e70b82ff..270d9aef9897d62c4edcf7c1c2874c738b284f8c 100644
--- a/app/assets/javascripts/subscriptions.js
+++ b/app/assets/javascripts/subscriptions.js
@@ -3,11 +3,11 @@ $(() => {
     const $tgt = $(evt.target);
     const $sub = $tgt.parents('details');
     const subscriptionId = $sub.data('sub-id');
-    const value = $tgt.is(':checked') ? 1 : 0;
+    const value = !!$tgt.is(':checked');
 
     const resp = await fetch(`/subscriptions/${subscriptionId}/enable`, {
       method: 'POST',
-      headers: { 'Accept': 'application/json', 'X-CSRF-Token': QPixel.csrfToken() },
+      headers: { 'Accept': 'application/json', 'X-CSRF-Token': QPixel.csrfToken(), 'Content-Type': 'application/json' },
       body: JSON.stringify({enabled: value})
     });
     const data = await resp.json();
diff --git a/app/assets/javascripts/tags.js b/app/assets/javascripts/tags.js
index be3e3af18223b017acd217b90193bfc1f3d3f95b..78ea2e368bd41dbf8fee999fd187ea9708924297 100644
--- a/app/assets/javascripts/tags.js
+++ b/app/assets/javascripts/tags.js
@@ -15,4 +15,20 @@ $(() => {
       }
     });
   });
+
+  $('.js-add-required-tag').on('click', ev => {
+    const $tgt = $(ev.target);
+    const useIds = $tgt.attr('data-use-ids') === 'true';
+    const tagId = $tgt.attr('data-tag-id');
+    const tagName = $tgt.attr('data-tag-name');
+    const $select = $tgt.parents('.form-group').find('select');
+    const existing = $select.find(`option[value=${tagId}]`);
+    if (existing.length > 0) {
+      $select.val([useIds ? tagId : tagName, ...($select.val() || [])]).trigger('change');
+    }
+    else {
+      const option = new Option(tagName, useIds ? tagId : tagName, false, true);
+      $tgt.parents('.form-group').find('select').append(option).trigger('change');
+    }
+  });
 });
\ No newline at end of file
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 869586f065131733eddae9440aad9fc0926a7787..f9283168167c434b14d1f9fee37ca91599d17751 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -193,3 +193,16 @@ hr {
     height: 1.25em;
   }
 }
+
+.inbox {
+  max-width: 400px;
+}
+
+.inbox--container {
+  max-height: 600px;
+  overflow-y: scroll;
+}
+
+.header--container {
+  align-items: center !important;
+}
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index c02a8086bab456af96d5c2beb2e937668b4db025..297cf2eaf1478873488b3602350f197ce4746dfd 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -88,6 +88,10 @@ pre.unformatted {
 
 .badge.is-tag.is-outlined {
   border: 1px solid #001db1;
+
+  &.is-red {
+    border: 1px solid #b3001e;
+  }
 }
 
 .badge.is-tag.is-small {
diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb
index 87fc87b40caa1060949abb19e16acb9aaa21a00e..cadaa6bffd1cbbadaed45330c0ae4e0c43c52fd3 100644
--- a/app/controllers/admin_controller.rb
+++ b/app/controllers/admin_controller.rb
@@ -1,6 +1,7 @@
 # Web controller. Provides authenticated actions for use by administrators.
 class AdminController < ApplicationController
   before_action :verify_admin
+  before_action :verify_global_admin, only: [:admin_email, :send_admin_email]
 
   def index; end
 
@@ -32,4 +33,14 @@ class AdminController < ApplicationController
     @privilege.update(threshold: params[:threshold])
     render json: { status: 'OK', privilege: @privilege }, status: 202
   end
+
+  def admin_email; end
+
+  def send_admin_email
+    Thread.new do
+      AdminMailer.with(body_markdown: params[:body_markdown], subject: params[:subject]).to_moderators.deliver_now
+    end
+    flash[:success] = 'Your email is being sent.'
+    redirect_to admin_path
+  end
 end
diff --git a/app/controllers/answers_controller.rb b/app/controllers/answers_controller.rb
index 944e9dd52c159c8d64c7eef9738db029ba34e962..a1189966702d0b3f9b8416ded7a3c06ea8621b14 100644
--- a/app/controllers/answers_controller.rb
+++ b/app/controllers/answers_controller.rb
@@ -31,15 +31,18 @@ class AnswersController < ApplicationController
   def edit; end
 
   def update
-    unless current_user&.has_post_privilege?('Edit', @answer)
+    can_post_in_category = @answer.parent.category.present? &&
+                           (@answer.parent.category.min_trust_level || -1) <= current_user&.trust_level
+    unless current_user&.has_post_privilege?('Edit', @answer) && can_post_in_category
       return update_as_suggested_edit
     end
 
     PostHistory.post_edited(@answer, current_user, before: @answer.body_markdown,
                             after: params[:answer][:body_markdown], comment: params[:edit_comment])
     if @answer.update(answer_params.merge(body: helpers.render_markdown(params[:answer][:body_markdown]),
-                                          last_activity: DateTime.now, last_activity_by: current_user))
-      redirect_to url_for(controller: :questions, action: :show, id: @answer.parent.id)
+                                          last_activity: DateTime.now, last_activity_by: current_user,
+                                          license_id: @answer.license_id))
+      redirect_to share_answer_path(qid: @answer.parent_id, id: @answer.id)
     else
       render :edit
     end
@@ -61,7 +64,7 @@ class AnswersController < ApplicationController
     if @edit.save
       @answer.user.create_notification("Edit suggested on your answer to #{@answer.parent.title.truncate(50)}",
                                        share_answer_url(qid: @answer.parent_id, id: @answer.id))
-      redirect_to url_for(controller: :questions, action: :show, id: @answer.parent.id)
+      redirect_to share_answer_path(qid: @answer.parent_id, id: @answer.id)
     else
       @post.errors = @edit.errors
       render :edit
@@ -121,7 +124,7 @@ class AnswersController < ApplicationController
   private
 
   def answer_params
-    params.require(:answer).permit(:body_markdown)
+    params.require(:answer).permit(:body_markdown, :license_id)
   end
 
   def set_answer
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 8496a531cd6089ebb807402045da02701de22490..da7b96cdd67a1b2f57a7dd3f5cd6f1f03e335032 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -53,6 +53,14 @@ class ApplicationController < ActionController::Base
     true
   end
 
+  def verify_global_moderator
+    if !user_signed_in? || !(current_user.is_global_moderator || current_user.is_global_admin)
+      render 'errors/not_found', layout: 'without_sidebar', status: 404
+      return false
+    end
+    true
+  end
+
   def check_your_privilege(name, post = nil, render_error = true)
     unless current_user&.has_privilege?(name) || (current_user&.has_post_privilege?(name, post) if post)
       @privilege = Privilege.find_by(name: name)
diff --git a/app/controllers/articles_controller.rb b/app/controllers/articles_controller.rb
index efe1697a6402d37db5cba6a982ec5dc094cfa60b..2615e74ce150997649a96ef6d88b8d5b396397c0 100644
--- a/app/controllers/articles_controller.rb
+++ b/app/controllers/articles_controller.rb
@@ -15,7 +15,9 @@ class ArticlesController < ApplicationController
   def edit; end
 
   def update
-    unless current_user&.has_post_privilege?('Edit', @article)
+    can_post_in_category = @article.category.present? &&
+                           (@article.category.min_trust_level || -1) <= current_user&.trust_level
+    unless current_user&.has_post_privilege?('Edit', @article) && can_post_in_category
       return update_as_suggested_edit
     end
 
@@ -25,7 +27,7 @@ class ArticlesController < ApplicationController
     if @article.update(article_params.merge(tags_cache: params[:article][:tags_cache]&.reject { |e| e.to_s.empty? },
                                             body: body_rendered, last_activity: DateTime.now,
                                             last_activity_by: current_user))
-      redirect_to article_path(@article)
+      redirect_to share_article_path(@article)
     else
       render :edit
     end
@@ -57,7 +59,7 @@ class ArticlesController < ApplicationController
     if @edit.save
       @article.user.create_notification("Edit suggested on your post #{@article.title.truncate(50)}",
                                         article_url(@article))
-      redirect_to article_path(@article.id)
+      redirect_to share_article_path(@article)
     else
       @post.errors = @edit.errors
       render :edit
diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb
index 987a13ac12d6495cd9ae81c8d903a7ee9af8eac8..04646b20948ef1a554c44936d0ea9891d33c514e 100644
--- a/app/controllers/categories_controller.rb
+++ b/app/controllers/categories_controller.rb
@@ -82,7 +82,7 @@ class CategoriesController < ApplicationController
     params.require(:category).permit(:name, :short_wiki, :tag_set_id, :is_homepage, :min_trust_level, :button_text,
                                      :color_code, :min_view_trust_level, :license_id, :sequence,
                                      display_post_types: [], post_type_ids: [], required_tag_ids: [],
-                                     topic_tag_ids: [])
+                                     topic_tag_ids: [], moderator_tag_ids: [])
   end
 
   def verify_view_access
@@ -94,7 +94,8 @@ class CategoriesController < ApplicationController
   def set_list_posts
     sort_params = { activity: { last_activity: :desc }, age: { created_at: :desc }, score: { score: :desc },
                     lottery: [Arel.sql('(RAND() - ? * DATEDIFF(CURRENT_TIMESTAMP, posts.created_at)) DESC'),
-                              SiteSetting['LotteryAgeDeprecationSpeed']] }
+                              SiteSetting['LotteryAgeDeprecationSpeed']],
+                    native: Arel.sql('att_source IS NULL DESC, last_activity DESC') }
     sort_param = sort_params[params[:sort]&.to_sym] || { last_activity: :desc }
     @posts = @category.posts.undeleted.where(post_type_id: @category.display_post_types)
                       .includes(:post_type, :tags).list_includes.paginate(page: params[:page], per_page: 50)
diff --git a/app/controllers/errors_controller.rb b/app/controllers/errors_controller.rb
index b37062c00ed73a175704f68c648a400b74a8fcbb..dd44d5cb1e2e8b6313ac130f31cd35d638c50ac5 100644
--- a/app/controllers/errors_controller.rb
+++ b/app/controllers/errors_controller.rb
@@ -13,6 +13,10 @@ class ErrorsController < ApplicationController
     }
     puts "  Error type #{@exception&.class}, status code #{@status}"
 
+    if @exception&.class == ActionView::MissingTemplate
+      @status = 404
+    end
+
     if @status == 500
       @log = ErrorLog.create(community: RequestContext.community, user: current_user, klass: @exception&.class,
                              message: @exception&.message, backtrace: @exception&.backtrace&.join("\n"),
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index d7afffba319afe747438f5dc0df2f5a14cf4c8a8..1c648369cecb6da3ac4140c7eee8670b86174b6d 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -86,12 +86,20 @@ class PostsController < ApplicationController
 
   def document
     @post = Post.unscoped.where(doc_slug: params[:slug], community_id: [RequestContext.community_id, nil]).first
-    if @post.help_category == '$Disabled'
+    if @post&.help_category == '$Disabled'
+      not_found
+    end
+    if @post&.help_category == '$Moderator' && !current_user&.is_moderator
       not_found
     end
   end
 
   def upload
+    unless ActiveStorage::Variant::WEB_IMAGE_CONTENT_TYPES.include? params[:file].content_type
+      acceptable = ActiveStorage::Variant::WEB_IMAGE_CONTENT_TYPES.map { |s| s.gsub('image/', '') }
+      render json: { error: "Images must be one of #{acceptable.join(', ')}" }, status: 400
+      return
+    end
     @blob = ActiveStorage::Blob.create_after_upload!(io: params[:file], filename: params[:file].original_filename,
                                                      content_type: params[:file].content_type)
     render json: { link: uploaded_url(@blob.key) }
@@ -138,6 +146,23 @@ class PostsController < ApplicationController
     render json: { success: true }
   end
 
+  def save_draft
+    key = "saved_post.#{current_user.id}.#{params[:path]}"
+    saved_at = "saved_post_at.#{current_user.id}.#{params[:path]}"
+    RequestContext.redis.set key, params[:post]
+    RequestContext.redis.set saved_at, DateTime.now.iso8601
+    RequestContext.redis.expire key, 86_400 * 7
+    RequestContext.redis.expire saved_at, 86_400 * 7
+    render json: { success: true, key: key }
+  end
+
+  def delete_draft
+    key = "saved_post.#{current_user.id}.#{params[:path]}"
+    saved_at = "saved_post_at.#{current_user.id}.#{params[:path]}"
+    RequestContext.redis.del key, saved_at
+    render json: { success: true }
+  end
+
   private
 
   def new_post_params
diff --git a/app/controllers/questions_controller.rb b/app/controllers/questions_controller.rb
index f1210bcc6f647212a314791a7c3fd60ab9eb756b..e775ccec0a70195c366588e2ff6017d8b18b307f 100644
--- a/app/controllers/questions_controller.rb
+++ b/app/controllers/questions_controller.rb
@@ -58,7 +58,9 @@ class QuestionsController < ApplicationController
   def edit; end
 
   def update
-    unless current_user&.has_post_privilege?('Edit', @question)
+    can_post_in_category = @question.category.present? &&
+                           (@question.category.min_trust_level || -1) <= current_user&.trust_level
+    unless current_user&.has_post_privilege?('Edit', @question) && can_post_in_category
       return update_as_suggested_edit
     end
 
@@ -68,7 +70,7 @@ class QuestionsController < ApplicationController
     if @question.update(question_params.merge(tags_cache: params[:question][:tags_cache]&.reject { |e| e.to_s.empty? },
                                               body: body_rendered, last_activity: DateTime.now,
                                               last_activity_by: current_user))
-      redirect_to url_for(controller: :questions, action: :show, id: @question.id)
+      redirect_to share_question_path(@question)
     else
       render :edit
     end
@@ -100,7 +102,7 @@ class QuestionsController < ApplicationController
     if @edit.save
       @question.user.create_notification("Edit suggested on your post #{@question.title.truncate(50)}",
                                          question_url(@question))
-      redirect_to url_for(controller: :questions, action: :show, id: @question.id)
+      redirect_to share_question_path(@question)
     else
       @post.errors = @edit.errors
       render :edit
diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb
index 17cb0be1cf4cdf50609f8be18f9089f31bbeaf37..fb201d15d6308171892a2dc3de8dba4cc46b75aa 100644
--- a/app/controllers/reports_controller.rb
+++ b/app/controllers/reports_controller.rb
@@ -1,10 +1,43 @@
 class ReportsController < ApplicationController
   before_action :authenticate_user!
-  before_action :verify_moderator
+  before_action :verify_moderator, except: [:users_global, :subs_global, :posts_global]
+  before_action :verify_global_moderator, only: [:users_global, :subs_global, :posts_global]
 
-  def users; end
+  def users
+    @users = User.joins(:community_users).where(community_users: { community_id: RequestContext.community_id })
+                 .where("users.email NOT LIKE '%localhost'")
+                 .where('users.created_at >= ?', 1.year.ago).group_by_week(:created_at).count
+  end
 
-  def subscriptions; end
+  def subscriptions
+    @subs = Subscription.where('created_at >= ?', 1.year.ago)
+    @types = Subscription.all.group(:type).count
+  end
 
-  def posts; end
+  def posts
+    @questions = Question.where('created_at >= ?', 1.year.ago).undeleted
+    @answers = Answer.where('created_at >= ?', 1.year.ago).undeleted
+    @comments = Comment.where('created_at >= ?', 1.year.ago).undeleted
+    @this_month = Post.where('created_at >= ?', 1.month.ago).undeleted
+  end
+
+  def users_global
+    @users = User.where("users.email NOT LIKE '%localhost'")
+                 .where('users.created_at >= ?', 1.year.ago).group_by_week(:created_at).count
+    render :users
+  end
+
+  def subs_global
+    @subs = Subscription.unscoped.where('created_at >= ?', 1.year.ago)
+    @types = Subscription.unscoped.all.group(:type).count
+    render :subscriptions
+  end
+
+  def posts_global
+    @questions = Post.unscoped.where(post_type_id: Question.post_type_id).where('created_at >= ?', 1.year.ago).undeleted
+    @answers = Post.unscoped.where(post_type_id: Answer.post_type_id).where('created_at >= ?', 1.year.ago).undeleted
+    @comments = Comment.unscoped.where('created_at >= ?', 1.year.ago).undeleted
+    @this_month = Post.unscoped.where('created_at >= ?', 1.month.ago).undeleted
+    render :posts
+  end
 end
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index e1ff7d3caa688b7c2a8fadde8e98ad9212473ac6..62f4a4334104f7ae8ca46f1be3e3b251f9655773 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -15,5 +15,11 @@ class SearchController < ApplicationController
                                  score: :score, age: :created_at)
                end
              end
+    @count = begin
+               @posts&.count
+             rescue
+               @posts = nil
+               flash[:danger] = 'Your search syntax is incorrect.'
+             end
   end
 end
diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb
index 8be144d4e92a46843984081f6e0467a60289fdc1..94cb947876df33518dca1c99b273a89b6d11b5ed 100644
--- a/app/controllers/subscriptions_controller.rb
+++ b/app/controllers/subscriptions_controller.rb
@@ -61,6 +61,8 @@ class SubscriptionsController < ApplicationController
       'new questions classed as interesting'
     when 'category'
       "new questions in the category '#{Category.find_by(id: qualifier || params[:qualifier])&.name}'"
+    when 'moderators'
+      'announcements and newsletters for moderators'
     else
       'nothing, apparently. How did you get here, again?'
     end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 5403a1b1d9096e826755ea966cd23b7e6d3d6df6..fa8f9df840fae151613c520d90da9aba8ccc42fc 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -13,7 +13,7 @@ class UsersController < ApplicationController
              else
                user_scope.order(sort_param => :desc)
              end.paginate(page: params[:page], per_page: 48)
-    @post_counts = Post.where(user_id: @users.pluck(:id).uniq).group(:user_id, :post_type_id).count(:post_type_id)
+    @post_counts = Post.where(user_id: @users.pluck(:id).uniq).group(:user_id).count
   end
 
   def show
@@ -21,25 +21,9 @@ class UsersController < ApplicationController
   end
 
   def posts
-    post_types = { questions: Question, answers: Answer }
-    unless post_types.include? params[:type].to_sym
-      respond_to do |format|
-        format.html do
-          render plain: 'No type or invalid type specified (must be one of questions, answers)', status: 400
-        end
-        format.json do
-          render json: { status: 'invalid',
-                         message: 'No type or invalid type specified (must be one of questions, answers)' },
-                 status: 400
-        end
-      end
-      return
-    end
-
-    model = post_types[params[:type].to_sym]
-    @posts = model.undeleted.where(user: @user).user_sort({ term: params[:sort], default: :score },
-                                                          age: :created_at, score: :score)
-                  .paginate(page: params[:page], per_page: 25)
+    @posts = Post.undeleted.where(user: @user).user_sort({ term: params[:sort], default: :score },
+                                                         age: :created_at, score: :score)
+                 .paginate(page: params[:page], per_page: 25)
     respond_to do |format|
       format.html do
         render :posts
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 6bf88bd078809cea834cacb1fe68dfc9077c229b..28106eb7748e5a2a03f7b350ff58238066f622d5 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -10,6 +10,8 @@ module SearchHelper
     { qualifiers: qualifiers, search: search }
   end
 
+  # rubocop:disable Metrics/CyclomaticComplexity
+  # rubocop:disable Metrics/MethodLength
   def qualifiers_to_sql(qualifiers)
     valid_value = {
       date: /^[<>=]{0,2}\d+(?:s|m|h|d|w|mo|y)?$/,
@@ -26,7 +28,7 @@ module SearchHelper
         next unless value.match?(valid_value[:numeric])
 
         operator, val = numeric_value_sql value
-        ["score #{operator.present? ? operator : '='} ?", val.to_i]
+        ["score #{operator.present? ? operator : '='} ?", val.to_f]
       when 'created'
         next unless value.match?(valid_value[:date])
 
@@ -38,6 +40,21 @@ module SearchHelper
 
         operator, val = numeric_value_sql value
         ["user_id #{operator.present? ? operator : '='} ?", val.to_i]
+      when 'upvotes'
+        next unless value.match?(valid_value[:numeric])
+
+        operator, val = numeric_value_sql value
+        ["upvotes #{operator.present? ? operator : '='} ?", val.to_i]
+      when 'downvotes'
+        next unless value.match?(valid_value[:numeric])
+
+        operator, val = numeric_value_sql value
+        ["downvotes #{operator.present? ? operator : '='} ?", val.to_i]
+      when 'votes'
+        next unless value.match?(valid_value[:numeric])
+
+        operator, val = numeric_value_sql value
+        ["(upvotes - downvotes) #{operator.present? ? operator : '='}", val.to_i]
       end
     end.compact
 
@@ -47,6 +64,8 @@ module SearchHelper
 
     Arel.sql(sql)
   end
+  # rubocop:enable Metrics/CyclomaticComplexity
+  # rubocop:enable Metrics/MethodLength
 
   def numeric_value_sql(value)
     operator = ''
diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb
index 7cff278796e005a6dd1b2607c4b9d9c43756bdea..24185619b74b7f546f76529149a11dc68d1dc00d 100644
--- a/app/helpers/tags_helper.rb
+++ b/app/helpers/tags_helper.rb
@@ -1,7 +1,10 @@
 module TagsHelper
-  def category_sort_tags(tags, required_ids, topic_ids)
+  def category_sort_tags(tags, required_ids, topic_ids, moderator_ids)
     tags
       .to_a
-      .sort_by { |t| [required_ids.include?(t.id) ? 0 : 1, topic_ids.include?(t.id) ? 0 : 1, t.id] }
+      .sort_by do |t|
+        [required_ids.include?(t.id) ? 0 : 1, moderator_ids.include?(t.id) ? 0 : 1,
+         topic_ids.include?(t.id) ? 0 : 1, t.id]
+      end
   end
 end
diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb
new file mode 100644
index 0000000000000000000000000000000000000000..fd1dcaf92d6961377bff83572799af286822f44f
--- /dev/null
+++ b/app/mailers/admin_mailer.rb
@@ -0,0 +1,13 @@
+class AdminMailer < ApplicationMailer
+  default from: 'moderators-noreply@codidact.com'
+
+  def to_moderators
+    @subject = params[:subject]
+    @body_markdown = params[:body_markdown]
+    query = 'SELECT DISTINCT u.email FROM subscriptions s INNER JOIN users u ON s.user_id = u.id ' \
+            "INNER JOIN community_users cu ON cu.user_id = u.id WHERE s.type = 'moderators' AND " \
+            '(u.is_global_admin = 1 OR u.is_global_moderator = 1 OR cu.is_admin = 1 OR cu.is_moderator = 1)'
+    emails = ActiveRecord::Base.connection.execute(query).to_a.flatten
+    mail subject: "Codidact Moderators: #{@subject}", bcc: emails
+  end
+end
diff --git a/app/mailers/subscription_mailer.rb b/app/mailers/subscription_mailer.rb
index 3fb4ae0b6001c60ee2a9b7b4aa488cfe29faf5b2..4a886ed1c5075f5f0b835b73e9a494c9d9f2a1aa 100644
--- a/app/mailers/subscription_mailer.rb
+++ b/app/mailers/subscription_mailer.rb
@@ -3,6 +3,8 @@ class SubscriptionMailer < ApplicationMailer
     @subscription = params[:subscription]
     @questions = @subscription.questions&.includes(:user) || []
 
+    return if @subscription.type == 'Moderators'
+
     if @questions.empty?
       return
     end
diff --git a/app/models/category.rb b/app/models/category.rb
index 7a1f7a25fba3ab946da443fa83c28749edb77ae7..a42b770134b709e23ec6aff556c18d05d4c56103 100644
--- a/app/models/category.rb
+++ b/app/models/category.rb
@@ -4,6 +4,7 @@ class Category < ApplicationRecord
   has_and_belongs_to_many :post_types
   has_and_belongs_to_many :required_tags, class_name: 'Tag', join_table: 'categories_required_tags'
   has_and_belongs_to_many :topic_tags, class_name: 'Tag', join_table: 'categories_topic_tags'
+  has_and_belongs_to_many :moderator_tags, class_name: 'Tag', join_table: 'categories_moderator_tags'
   has_many :posts
   belongs_to :tag_set
   belongs_to :license
diff --git a/app/models/post.rb b/app/models/post.rb
index 087485bba4de263799206535305929a56e6c85ae..a6352394c01a6b54d88b86605d5fa123d7df1828 100644
--- a/app/models/post.rb
+++ b/app/models/post.rb
@@ -1,4 +1,3 @@
-# rubocop:disable Metrics/ClassLength
 class Post < ApplicationRecord
   include CommunityRelated
 
@@ -33,10 +32,11 @@ class Post < ApplicationRecord
   validate :category_allows_post_type
   validate :license_available
   validate :required_tags?, if: -> { question? || article? }
+  validate :moderator_tags, if: -> { question? || article? }
 
   scope :undeleted, -> { where(deleted: false) }
   scope :deleted, -> { where(deleted: true) }
-  scope :qa_only, -> { where(post_type_id: [Question.post_type_id, Answer.post_type_id]) }
+  scope :qa_only, -> { where(post_type_id: [Question.post_type_id, Answer.post_type_id, Article.post_type_id]) }
   scope :list_includes, -> { includes(:user, :tags, user: :avatar_attachment) }
 
   after_save :check_attribution_notice
@@ -222,8 +222,8 @@ class Post < ApplicationRecord
 
   def no_spaces_in_tags
     tags_cache.each do |tag|
-      if tag.include? ' '
-        errors.add(:tags, 'may not include spaces - use hyphens for multiple-word tags')
+      if tag.include?(' ') || tag.include?('_')
+        errors.add(:tags, 'may not include spaces or underscores - use hyphens for multiple-word tags')
       end
     end
   end
@@ -259,10 +259,22 @@ class Post < ApplicationRecord
     end
   end
 
+  def moderator_tags
+    mod_tags = category&.moderator_tags&.map(&:name)
+    return unless mod_tags.present? && !mod_tags.empty?
+    return if RequestContext.user&.is_moderator
+
+    sc = changes
+    return unless sc.include? 'tags_cache'
+
+    if (sc['tags_cache'][0] || []) & mod_tags != (sc['tags_cache'][1] || []) & mod_tags
+      errors.add(:base, "You don't have permission to change moderator-only tags.")
+    end
+  end
+
   def update_category_activity
     if saved_changes.include? 'last_activity'
       category.update_activity(last_activity)
     end
   end
 end
-# rubocop:enable Metrics/ClassLength
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
index 10aa8416db92d8fa17f5ec96fdd23773a84dd24b..3b832bb1ba6082eab6085de5a0eb4fd26fe470c6 100644
--- a/app/models/subscription.rb
+++ b/app/models/subscription.rb
@@ -5,7 +5,7 @@ class Subscription < ApplicationRecord
 
   belongs_to :user
 
-  validates :type, presence: true, inclusion: ['all', 'tag', 'user', 'interesting', 'category']
+  validates :type, presence: true, inclusion: ['all', 'tag', 'user', 'interesting', 'category', 'moderators']
   validates :frequency, numericality: { minimum: 1, maximum: 90 }
 
   validate :qualifier_presence
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 65024e43b4894060fdb8b705445547c91a27a145..1fc46a351aa920c318c1ae2d72b6d444b67f671c 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -5,6 +5,7 @@ class Tag < ApplicationRecord
   belongs_to :tag_set
 
   def self.search(term)
-    where('name LIKE ?', "#{sanitize_sql_like(term)}%")
+    where('name LIKE ?', "%#{sanitize_sql_like(term)}%")
+      .order(sanitize_sql_array(['name LIKE ? DESC, name', "#{sanitize_sql_like(term)}%"]))
   end
 end
diff --git a/app/models/user.rb b/app/models/user.rb
index 90e41012d22bfe0fb6ab9afef81df28958def14f..47dcfbd6bc3e33c69e634aca8075b7e529bf7b6e 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -80,7 +80,7 @@ class User < ApplicationRecord
   end
 
   def is_moderator
-    is_global_moderator || community_user&.is_moderator || false
+    is_global_moderator || community_user&.is_moderator || is_admin || false
   end
 
   def is_admin
diff --git a/app/views/admin/admin_email.html.erb b/app/views/admin/admin_email.html.erb
new file mode 100644
index 0000000000000000000000000000000000000000..1f0d28a1857756b5f3f299c01aea5ba60db2c11a
--- /dev/null
+++ b/app/views/admin/admin_email.html.erb
@@ -0,0 +1,21 @@
+<%= render 'posts/markdown_script' %>
+
+<h1>Email moderators</h1>
+<p>
+  This tool allows you to send to the moderators' email distribution list, which will reach any current moderator who is
+  subscribed to it. Use sparingly and non-spammily. Your email will be sent in both HTML (formatted)
+  and plain text (unformatted) forms, so format in a way that will be legible when Markdown is removed.
+</p>
+
+<%= form_with url: send_moderator_email_path do |f| %>
+  <div class="form-group">
+    <%= f.label :subject, 'Subject', class: 'form-element' %>
+    <%= f.text_field :subject,  class: 'form-element' %>
+  </div>
+
+  <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body', post: nil %>
+
+  <div class="post-preview"></div>
+
+  <%= f.submit 'Send', class: 'button is-filled' %>
+<% end %>
\ No newline at end of file
diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb
index f39f74afb39009d16d7903a7aa566974d7985327..a3cd20e154c81d828d1e5accb8b0efec2011cfed 100644
--- a/app/views/admin/index.html.erb
+++ b/app/views/admin/index.html.erb
@@ -24,6 +24,15 @@
         </div>
       </div>
     </div>
+
+    <div class="grid--cell is-4-lg is-6-md is-12-sm">
+      <div class="widget">
+        <div class="widget--body">
+          <i class="fas fa-globe has-color-red-700"></i> <i class="fas fa-envelope"></i>
+          <%= link_to 'Email Moderators', moderator_email_path %>
+        </div>
+      </div>
+    </div>
   <% end %>
 
   <div class="grid--cell is-4-lg is-6-md is-12-sm">
diff --git a/app/views/admin_mailer/to_moderators.html.erb b/app/views/admin_mailer/to_moderators.html.erb
new file mode 100644
index 0000000000000000000000000000000000000000..f739db70ca5f6f917e5ca9ee4ec2ee70bd3ed69b
--- /dev/null
+++ b/app/views/admin_mailer/to_moderators.html.erb
@@ -0,0 +1,2 @@
+<%= raw(sanitize(ApplicationController.helpers.render_markdown(@body_markdown),
+                 scrubber: ApplicationController.helpers.scrubber)) %>
\ No newline at end of file
diff --git a/app/views/admin_mailer/to_moderators.text.erb b/app/views/admin_mailer/to_moderators.text.erb
new file mode 100644
index 0000000000000000000000000000000000000000..bab07b99ad8c3835248ecb69e2ce3e0f7bbb1989
--- /dev/null
+++ b/app/views/admin_mailer/to_moderators.text.erb
@@ -0,0 +1 @@
+<%= ApplicationController.helpers.strip_markdown @body_markdown %>
\ No newline at end of file
diff --git a/app/views/answers/_new.html.erb b/app/views/answers/_new.html.erb
index 9e9aad547b6f22f6f8598d075ad41df64140d154..2462917fde84f62ea505689b33c2cf97fd9252fb 100644
--- a/app/views/answers/_new.html.erb
+++ b/app/views/answers/_new.html.erb
@@ -13,10 +13,31 @@
 <%= render 'posts/markdown_script' %>
 <%= render 'posts/image_upload' %>
 
-<%= form_for answer, url: { controller: :answers, action: :create } do |f| %>
-  <%= f.hidden_field :category, value: parent.category %>
+<%= form_for answer, url: create_answer_path do |f| %>
+  <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body', post: answer %>
 
-  <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body' %>
+  <div class="form-group">
+    <%= f.label :license_id, 'License', class: 'form-element' %>
+    <span class="form-caption">
+      <% site_default = License.site_default %>
+      <% category_default = defined?(@category) ? @category.license : @question.category.license %>
+      <% if site_default.present? %>
+        site default: <a href="javascript:void(0)" class="js-license-autofill" data-license-id="<%= site_default.id %>">
+          <%= site_default.name %>
+        </a>
+      <% end %>
+      <% if site_default.present? && category_default.present? %>
+        &middot;
+      <% end %>
+      <% if category_default.present? %>
+        category default: <a href="javascript:void(0)" class="js-license-autofill" data-license-id="<%= category_default.id %>">
+          <%= category_default.name %>
+        </a>
+      <% end %>
+    </span>
+    <%= f.select :license_id, options_for_select(License.enabled.default_order(@category).map { |l| [l.name, l.id] }),
+                 {}, class: 'form-element' %>
+  </div>
 
   <div class="post-preview"></div>
   <div class="field">
diff --git a/app/views/answers/edit.html.erb b/app/views/answers/edit.html.erb
index 8eca5b7dda24559c5429479bcdced9ae79ab7601..b8c9756564a46a008acc23a57fdc6b50252d24e2 100644
--- a/app/views/answers/edit.html.erb
+++ b/app/views/answers/edit.html.erb
@@ -16,7 +16,7 @@
 <%= render 'posts/image_upload' %>
 
 <%= form_for @answer, url: { controller: :answers, action: :update } do |f| %>
-  <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body' %>
+  <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body', post: @answer %>
   <div class="post-preview"></div>
   <div class="form-group">
     <%= label_tag :edit_comment, 'Edit comment', class: "form-element" %>
@@ -27,6 +27,7 @@
   </div>
   <div class="form-group">
     <%= f.submit (check_your_post_privilege(@answer, 'Edit') ? "Save changes" : "Suggest changes"), class: "button is-filled" %>
+    <%= link_to 'Cancel', question_path(@answer.parent), class: 'button is-outlined is-muted' %>
   </div>
 <% end %>
 
diff --git a/app/views/application/dashboard.html.erb b/app/views/application/dashboard.html.erb
index 9d4c0bd299711ad55f3659570a01a1b5754b2ad1..08f884632edaa7eb699b955d8b0ec00faf71c391 100644
--- a/app/views/application/dashboard.html.erb
+++ b/app/views/application/dashboard.html.erb
@@ -1,49 +1,58 @@
 <h1>Dashboard</h1>
 <div class="grid community-list">
-<% @communities.each do |c| %>
+  <% @communities.each do |c| %>
     <% categories = Category.unscoped.where(community: c).order(sequence: :asc, id: :asc) %>
     <% settings = SiteSetting.for_community_id(c.id) %>
     <% logo_setting = settings.find_by(name: 'SiteLogoPath') %>
     <div class="grid--cell is-4 is-6-md is-12-sm">
-        <div class="widget">
-            <div class="widget--header is-complex">
-                <div class="has-text-align-center has-font-weight-bold has-font-size-display">
-                    <%= link_to "//"+c.host, class: "dashboard-full-center" do %>
-                        <% if !logo_setting.nil? %>
-                            <img src="<%= logo_setting&.typed %>" alt="<%= c.name %>">
-                        <% else %>
-                            <%= c.name %>
-                        <% end%>
-                    <% end %>
-                </div>
-            </div>
-            <div class="widget--body h-fw-bold h-bg-tertiary-050"><i class="fa fa-file-alt"></i> Posts</div>
-            <% categories.each do |cat| %>
-                <% next unless (cat.min_view_trust_level || -1) < 0 %>
-                <div class="widget--body">
-                <%= link_to "//"+c.host+category_path(cat) do %>
-                    <%= cat.name %>
-                    <% if user_signed_in? && cat.new_posts_for?(current_user) %>
-                        <span class="badge is-status" title="new posts for you"></span>
-                    <% end %>
-                <% end %>
-                </div>
-            <% end %>
-            <% if current_user&.is_global_moderator || current_user&.is_global_admin %>
-                <% open_flags = Flag.unscoped.where(community: c, status: nil).count %>
-                <% resolved_flags = Flag.unscoped.where(community: c).where.not(status: nil).count %>
-                <div class="widget--body h-fw-bold h-bg-tertiary-050">
-                    <i class="fa fa-flag"></i>
-                    <a href="<%= flag_queue_url(host: c.host) %>">Flags</a>
-                    (global mods only)
-                </div>
-                <div class="widget--body">
-                    <% if open_flags != 0 %>
-                        <div class="h-c-red-700 h-fw-bold"><span class="badge is-tag is-red is-small"><%= open_flags %></span> open flags</div>
-                    <% end %>
-                    <div><span class="badge is-tag is-muted is-small"><%= resolved_flags %></span> resolved flags</div>
-                </div>
+      <div class="widget">
+        <div class="widget--header is-complex">
+          <div class="has-text-align-center has-font-weight-bold has-font-size-display">
+            <%= link_to "//" + c.host, class: "dashboard-full-center" do %>
+              <% if !logo_setting.nil? %>
+                <img src="<%= logo_setting&.typed %>" alt="<%= c.name %>">
+              <% else %>
+                <%= c.name %>
+              <% end %>
             <% end %>
+          </div>
         </div>
+        <div class="widget--body h-fw-bold h-bg-tertiary-050"><i class="fa fa-file-alt"></i> Posts</div>
+        <% categories.each do |cat| %>
+          <% next unless (cat.min_view_trust_level || -1) < 0 %>
+          <div class="widget--body">
+            <%= link_to "//" + c.host + category_path(cat) do %>
+              <%= cat.name %>
+              <% if user_signed_in? && cat.new_posts_for?(current_user) %>
+                <span class="badge is-status" title="new posts for you"></span>
+              <% end %>
+            <% end %>
+          </div>
+        <% end %>
+        <% if current_user&.is_global_moderator || current_user&.is_global_admin %>
+          <% open_flags = Flag.unscoped.where(community: c, status: nil).count %>
+          <% resolved_flags = Flag.unscoped.where(community: c).where.not(status: nil).count %>
+          <div class="widget--body h-fw-bold h-bg-tertiary-050">
+            <i class="fa fa-flag"></i>
+            <a href="<%= flag_queue_url(host: c.host) %>">Flags</a>
+            (global mods only)
+          </div>
+          <div class="widget--body">
+            <% if open_flags != 0 %>
+              <div class="h-c-red-700 h-fw-bold"><span class="badge is-tag is-red is-small"><%= open_flags %></span>
+                open flags
+              </div>
+            <% end %>
+            <div><span class="badge is-tag is-muted is-small"><%= resolved_flags %></span> resolved flags</div>
+          </div>
+          <div class="widget--body h-fw-bold h-bg-tertiary-050">
+            <%= link_to 'Reports', "//#{c.host}#{users_report_path}" %>
+          </div>
+          <div class="widget--body h-fw-bold h-bg-tertiary-050">
+            <%= link_to 'Global Reports', global_users_report_path %>
+          </div>
+        <% end %>
+      </div>
     </div>
-<% end%>
+  <% end %>
+</div>
diff --git a/app/views/articles/_form.html.erb b/app/views/articles/_form.html.erb
index 4c95662b70fbeb039f85d753a1727854f77a6f16..68ad4a85af1d35bc3de24351048fd9c087a5a985 100644
--- a/app/views/articles/_form.html.erb
+++ b/app/views/articles/_form.html.erb
@@ -19,7 +19,7 @@
     <%= f.text_field :title, class: "form-element" %>
   </div>
 
-  <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body' %>
+  <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body', post: @article %>
 
   <div class="post-preview"></div>
 
@@ -42,6 +42,7 @@
   </div>
 
   <div class="form-group">
-    <%= f.submit check_your_post_privilege(@article, 'Edit') ? "Save changes" : "Suggest changes", class: "button is-filled" %><br/>
+    <%= f.submit check_your_post_privilege(@article, 'Edit') ? "Save changes" : "Suggest changes", class: "button is-filled" %>
+    <%= link_to 'Cancel', article_path(@article), class: 'button is-outlined is-muted' %>
   </div>
 <% end %>
diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb
index 72a582b49ea00656c899aafbcf983a815430c243..daf36e9451107c67bb35e60f9855ea50362b142f 100644
--- a/app/views/categories/_form.html.erb
+++ b/app/views/categories/_form.html.erb
@@ -108,7 +108,7 @@
   </div>
 
   <div class="form-group js-category-tags-group">
-    <%= f.label :required_tag_ids, 'Topic tags', class: 'form-element' %>
+    <%= f.label :topic_tag_ids, 'Topic tags', class: 'form-element' %>
     <span class="form-caption js-tags-group-caption">
       <span data-state="present" style="<%= @category.tag_set.nil? ? 'display: none' : '' %>">
         Tags that will be highlighted as the most important tag on a question.
@@ -125,5 +125,22 @@
                  data: { tag_set: @category.tag_set&.id, create: 'false', use_ids: 'true' }, disabled: disabled %>
   </div>
 
+  <div class="form-group js-category-tags-group">
+    <%= f.label :moderator_tag_ids, 'Moderator-only tags', class: 'form-element' %>
+    <span class="form-caption js-tags-group-caption">
+      <span data-state="present" style="<%= @category.tag_set.nil? ? 'display: none' : '' %>">
+        Tags that only moderators can use in this category.
+      </span>
+      <span data-state="absent" style="<%= @category.tag_set.nil? ? '' : 'display: none' %>">
+        Select a tag set first.
+      </span>
+    </span>
+
+    <%= f.select :moderator_tag_ids, options_for_select(@category.moderator_tags.map { |t| [t.name, t.id] },
+                                                        selected: @category.moderator_tag_ids),
+                 { include_blank: true }, multiple: true, class: 'form-element js-tag-select js-moderator-tags',
+                 data: { tag_set: @category.tag_set&.id, create: 'false', use_ids: 'true' }, disabled: disabled %>
+  </div>
+
   <%= f.submit 'Save', class: 'button is-filled' %>
 <% end %>
diff --git a/app/views/categories/show.html.erb b/app/views/categories/show.html.erb
index 4e2e09343c591e4b7c71d461f30177c76831a689..bf3413e6d1463e9e57e8047dd8b210b984083c22 100644
--- a/app/views/categories/show.html.erb
+++ b/app/views/categories/show.html.erb
@@ -32,6 +32,10 @@
                 class: "button is-muted is-outlined #{params[:sort] == 'age' ? 'is-active' : ''}" %>
     <%= link_to 'Score', query_url(sort: 'score'),
                 class: "button is-muted is-outlined #{params[:sort] == 'score' ? 'is-active' : ''}" %>
+    <% if SiteSetting['AllowContentTransfer'] %>
+      <%= link_to 'Native', query_url(sort: 'native'),
+                  class: "button is-muted is-outlined #{params[:sort] == 'native' ? 'is-active' : ''}" %>
+    <% end %>
     <% if @category.name == 'Q&A' %>
       <%= link_to 'Lottery', query_url(sort: 'lottery'),
                   class: "button is-muted is-outlined #{params[:sort] == 'lottery' ? 'is-active' : ''}" %>
diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb
index f43e5a94d818354d5a934b9153d82b9d33cbcbe8..bc3a9ce633366e9208448846dbe5e0c54096a094 100644
--- a/app/views/devise/registrations/new.html.erb
+++ b/app/views/devise/registrations/new.html.erb
@@ -31,14 +31,20 @@
     <%= f.password_field :password_confirmation, class: 'form-element', autocomplete: "new-password" %>
   </div>
 
-  <div class="js-errors has-color-red-600 has-font-size-caption has-padding-2"></div>
-  <%= f.submit "Sign up", class: "button is-filled is-very-large" %>
-
   <div class="notice has-margin-top-4">
-    <i class="fas fa-exclamation-circle"></i> By signing up, you're agreeing to the
-    <%= link_to 'terms of service', 'https://writing.codidact.com/policy/tos' %>.
+    <p>
+      <i class="fas fa-exclamation-circle"></i> By signing up, you're agreeing to the
+      <%= link_to 'terms of service', '/policy/tos' %>.
+    </p>
+    <p>
+      <i class="fas fa-info-circle"></i> Signing up creates an account for you across our network of sites.
+      When you visit any of our sites, your profile will automatically be ready and waiting for you.
+    </p>
   </div>
 
+  <div class="js-errors has-color-red-600 has-font-size-caption has-padding-2"></div>
+  <%= f.submit "Sign up", class: "button is-filled is-very-large" %>
+
 <% end %><br/>
 
 <%= render "devise/shared/links" %>
diff --git a/app/views/layouts/_head.html.erb b/app/views/layouts/_head.html.erb
index 1eebfff16003038cf87e058006a51eeca05c8ced..9f625e6169cbe9ab541fc774b7b2292c7ae6bea5 100644
--- a/app/views/layouts/_head.html.erb
+++ b/app/views/layouts/_head.html.erb
@@ -24,6 +24,7 @@
 <%= javascript_include_tag "https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.13.0/moment.min.js" %>
 <%= javascript_include_tag "https://cdn.jsdelivr.net/npm/select2@4.0.12/dist/js/select2.min.js" %>
 <%= javascript_include_tag "https://unpkg.com/@codidact/co-design@0.10.0/js/co-design.js" %>
+<%= javascript_include_tag "/assets/community/#{@community.host.split('.')[0]}.js" %>
 <%= javascript_include_tag 'application' %>
 
 <% if SiteSetting['MathJaxEnabled'] %>
diff --git a/app/views/posts/_article_list.html.erb b/app/views/posts/_article_list.html.erb
index 707c5729d7cdba5a5085a578f98ac064ff76eff0..01f889c880418a50564011ad6ebab20a8c649048 100644
--- a/app/views/posts/_article_list.html.erb
+++ b/app/views/posts/_article_list.html.erb
@@ -1,15 +1,20 @@
 <% active_user = post.last_activity_by || post.user %>
+<% show_type_tag ||= false %>
+<% show_category_tag ||= false %>
 <div class="item-list--item <%= post.deleted ? 'deleted-content' : '' %>">
   <div class="item-list--number-value">
     <span class="item-list--number">
-      <span class=" js-upvote-count">+<%= post.upvote_count %></span>
+      <span class=" js-upvote-count has-font-size-subheading">+<%= post.upvote_count %></span>
     </span>
     <span class="item-list--number">
-      <span class=" js-downvote-count">&minus;<%= post.downvote_count %></span>
+      <span class=" js-downvote-count has-font-size-subheading">&minus;<%= post.downvote_count %></span>
     </span>
   </div>
   <div class="item-list--text-value is-primary">
     <div class="post-list--title">
+      <% if show_category_tag %>
+        <span class="badge is-tag is-filled"><%= defined?(@category) ? @category.name : post.category.name %></span>
+      <% end %>
       <%= link_to post.title, share_article_path(post) %>
     </div>
     <p class="has-color-tertiary-600 has-float-right post-list--meta">
@@ -17,17 +22,19 @@
     </p>
     <div class="has-padding-top-2">
       <% category = defined?(@category) ? @category : post.category %>
-      <% if category.display_post_types.reject { |e| e.to_s.empty? }.size > 1 %>
+      <% if show_type_tag || category.display_post_types.reject { |e| e.to_s.empty? }.size > 1 %>
         <%= post_type_badge(post.post_type.name) %>
       <% end %>
       <% tag_set = post.tag_set %>
       <% required_ids = category&.required_tag_ids %>
+      <% moderator_ids = category&.moderator_tag_ids %>
       <% topic_ids = category&.topic_tag_ids %>
-      <% category_sort_tags(post.tags, required_ids, topic_ids).each do |tag| %>
-        <% required = required_ids&.include? tag.id %>
-        <% topic = topic_ids&.include? tag.id %>
+      <% category_sort_tags(post.tags, required_ids, topic_ids, moderator_ids).each do |tag| %>
+        <% required = required_ids&.include?(tag.id) ? 'is-filled' : '' %>
+        <% topic = topic_ids&.include?(tag.id) ? 'is-outlined' : '' %>
+        <% moderator = moderator_ids.include?(tag.id) ? 'is-red is-outlined' : '' %>
         <%= link_to tag.name, questions_tagged_path(tag_set: tag_set.id, tag: tag.name),
-                    class: "badge is-tag #{required ? 'is-filled' : ''} #{topic ? 'is-outlined' : ''}" %>
+                    class: "badge is-tag #{required} #{topic} #{moderator}" %>
       <% end %>
     </div>
   </div>
diff --git a/app/views/posts/_expanded.html.erb b/app/views/posts/_expanded.html.erb
index a2d9e63e89f463dfd98ac44fb9621aa8ff0d9012..8e8c31685adb37fee949f75ed70588ef98fd4e75 100644
--- a/app/views/posts/_expanded.html.erb
+++ b/app/views/posts/_expanded.html.erb
@@ -24,10 +24,10 @@
               <path d="M50,0 L100,50 L0,50 Z" fill="currentColor" />
             </svg>
         </button>
-        <div class="score has-font-size-subtitle  has-font-weight-medium js-upvote-count">
+        <div class="score has-font-size-subheading has-font-weight-medium js-upvote-count">
           +<%= post.upvote_count %>
         </div>
-        <div class="score has-font-size-subtitle  has-font-weight-medium js-downvote-count">
+        <div class="score has-font-size-subheading has-font-weight-medium js-downvote-count">
           &minus;<%= post.downvote_count %>
         </div>
         <button class="vote-button button is-icon-only-button <%= (existing_vote&.vote_type == -1) ? 'is-active' : '' %>"
@@ -116,13 +116,14 @@
             <div class="post--tags has-padding-2">
               <% tag_set = post.tag_set %>
               <% required_ids = post.category&.required_tag_ids %>
+              <% moderator_ids = post.category&.moderator_tag_ids %>
               <% topic_ids = post.category&.topic_tag_ids %>
-              <% category_sort_tags(post.tags, required_ids, topic_ids).each do |tag| %>
-                <% next if tag.nil? %>
-                <% required = required_ids&.include? tag.id %>
-                <% topic = topic_ids&.include? tag.id %>
+              <% category_sort_tags(post.tags, required_ids, topic_ids, moderator_ids).each do |tag| %>
+                <% required = required_ids&.include?(tag.id) ? 'is-filled' : '' %>
+                <% topic = topic_ids&.include?(tag.id) ? 'is-outlined' : '' %>
+                <% moderator = moderator_ids.include?(tag.id) ? 'is-red is-outlined' : '' %>
                 <%= link_to tag.name, questions_tagged_path(tag_set: tag_set.id, tag: tag.name),
-                            class: "badge is-tag #{required ? 'is-filled' : ''} #{topic ? 'is-outlined' : ''}" %>
+                            class: "badge is-tag #{required} #{topic} #{moderator}" %>
               <% end %>
             </div>
           <% end %>
diff --git a/app/views/posts/_form.html.erb b/app/views/posts/_form.html.erb
index 6e71b2d99b300b1136b8946fe95abf6a4ee98339..426dcc37acf6b175144ad01fd3b969ad51cdca6c 100644
--- a/app/views/posts/_form.html.erb
+++ b/app/views/posts/_form.html.erb
@@ -42,7 +42,7 @@
     <%= f.hidden_field :post_type_id %>
   <% end %>
 
-  <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body' %>
+  <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body', post: @post %>
 
   <div class="post-preview"></div>
 
@@ -58,7 +58,10 @@
       <span class="form-caption">
         Requires at least one of
         <% required_tags.each do |tag| %>
-          <span class="badge is-tag is-filled"><%= tag.name %></span>
+          <a class="badge is-tag is-filled js-add-required-tag" href="javascript:void(0)" data-tag-id="<%= tag.id %>"
+             data-tag-name="<%= tag.name %>">
+            <%= tag.name %>
+          </a>
         <% end %>
       </span>
     <% end %>
@@ -93,6 +96,7 @@
   <% end %>
 
   <div class="actions">
-    <%= f.submit "Save Post in #{@category.name}", class: 'button is-filled is-large' %>
+    <%= f.submit "Save Post in #{@category.name}", class: 'button is-filled' %>
+    <%= link_to 'Cancel', category_path(@category), class: 'button is-muted is-outlined' %>
   </div>
 <% end %>
\ No newline at end of file
diff --git a/app/views/posts/_help_center_posts.html.erb b/app/views/posts/_help_center_posts.html.erb
index 0121c18e23056fa890d2db7a712925e91a32c6d3..a0ea01b024212332d27488b9533717585d3d8e09 100644
--- a/app/views/posts/_help_center_posts.html.erb
+++ b/app/views/posts/_help_center_posts.html.erb
@@ -1,7 +1,18 @@
 <% posts.to_a.in_groups_of(3).map(&:compact).each do |row| %>
   <% row.each do |row_data| %>
+    <% next if row_data[0] == '$Moderator' && !current_user&.is_moderator %>
     <div class="widget">
-      <div class="widget--header"><%= row_data[0].present? ? row_data[0] : 'Uncategorized' %></div>
+      <div class="widget--header">
+        <% if row_data[0].present? %>
+          <% if row_data[0] == '$Moderator' %>
+            Moderator Information
+          <% else %>
+            <%= row_data[0] %>
+          <% end %>
+        <% else %>
+          Uncategorized
+        <% end %>
+      </div>
       <div class="widget--body">
         <% row_data[1].each do |post| %>
           <p>
diff --git a/app/views/posts/_image_upload.html.erb b/app/views/posts/_image_upload.html.erb
index 587a8348364658b9977113b9ba76ad29aef23839..99bb6b22e1fa1e2a4dee12af6e54131d5ff88fd3 100644
--- a/app/views/posts/_image_upload.html.erb
+++ b/app/views/posts/_image_upload.html.erb
@@ -1,7 +1,7 @@
 <div class="modal is-with-backdrop" id="markdown-image-upload">
   <div class="modal--container">
     <div class="modal--header">
-      <button class="button is-close-button modal--header-button">&times;</button>
+      <button class="button is-close-button modal--header-button" data-modal="#markdown-image-upload">&times;</button>
       Image upload
     </div>
     <div class="modal--body">
diff --git a/app/views/posts/_list.html.erb b/app/views/posts/_list.html.erb
index 675d24d8773bbb3d28fceb64096250b54fd25f62..84c10629287ab22f4ad9f233c7a49cf9309b3ab5 100644
--- a/app/views/posts/_list.html.erb
+++ b/app/views/posts/_list.html.erb
@@ -1,20 +1,22 @@
 <% is_question = post.post_type_id == Question.post_type_id %>
 <% is_meta = (is_question && post.meta?) || (!is_question && post.parent&.meta?) %>
 <% active_user = post.last_activity_by || post.user %>
+<% show_type_tag ||= false %>
+<% show_category_tag ||= false %>
 <div class="item-list--item <%= is_meta ? 'post__meta' : '' %> <%= post.deleted ? 'deleted-content' : '' %>">
   <div class="item-list--number-value">
     <span class="item-list--number">
-      <span class=" js-upvote-count">+<%= post.upvote_count %></span>
+      <span class="js-upvote-count has-font-size-subheading">+<%= post.upvote_count %></span>
     </span>
     <span class="item-list--number">
-      <span class=" js-downvote-count">&minus;<%= post.downvote_count %></span>
+      <span class="js-downvote-count has-font-size-subheading">&minus;<%= post.downvote_count %></span>
     </span>
   </div>
   <div class="item-list--text-value is-primary">
     <div class="post-list--title">
-      <%= link_to is_question ? share_question_path(post) : share_answer_path(qid: post.parent_id, id: post.id) do %>
-        <% if (is_question && post.meta?) || (!is_question && post.parent.meta?) %>
-          <span class="badge is-tag is-master-tag is-muted">meta</span>
+      <%= link_to generic_share_link(post) do %>
+        <% if show_category_tag %>
+          <span class="badge is-tag is-filled"><%= defined?(@category) ? @category.name : post.category.name %></span>
         <% end %>
         <%= is_question ? post.title : post.parent.title %>
         <%= is_question && post.closed ? "[closed]" : "" %>
@@ -26,22 +28,31 @@
           <%= pluralize(post.answer_count, 'answer') %>
         </span>&nbsp;&middot;&nbsp;	
       <% end %>	
-      last activity <%= time_ago_in_words(post.last_activity) %> ago by <%= link_to active_user.username, user_path(active_user) %>	
+      last activity <%= time_ago_in_words(post.last_activity) %> ago
+      by <%= link_to active_user.username, user_path(active_user) %>
     </p>
     <div class="has-padding-top-2">
       <% category = defined?(@category) ? @category : post.category %>
-      <% if category.display_post_types.reject { |e| e.to_s.empty? }.size > 1 %>
+      <% if show_type_tag || category.display_post_types.reject { |e| e.to_s.empty? }.size > 1 %>
         <%= post_type_badge(post.post_type.name) %>
       <% end %>
       <% if is_question %>
         <% tag_set = post.tag_set %>
-        <% required_ids = category&.required_tag_ids %>
-        <% topic_ids = category&.topic_tag_ids %>
-        <% category_sort_tags(post.tags, required_ids, topic_ids).each do |tag| %>
-          <% required = required_ids&.include? tag.id %>
-          <% topic = topic_ids&.include? tag.id %>
+        <% required_ids = post.category&.required_tag_ids %>
+        <% moderator_ids = post.category&.moderator_tag_ids %>
+        <% topic_ids = post.category&.topic_tag_ids %>
+        <% category_sort_tags(post.tags, required_ids, topic_ids, moderator_ids).each do |tag| %>
+          <% required = required_ids&.include?(tag.id) ? 'is-filled' : '' %>
+          <% topic = topic_ids&.include?(tag.id) ? 'is-outlined' : '' %>
+          <% moderator = moderator_ids.include?(tag.id) ? 'is-red is-outlined' : '' %>
           <%= link_to tag.name, questions_tagged_path(tag_set: tag_set.id, tag: tag.name),
-                      class: "badge is-tag #{required ? 'is-filled' : ''} #{topic ? 'is-outlined' : ''}" %>
+                      class: "badge is-tag #{required} #{topic} #{moderator}" %>
+        <% end %>
+      <% end %>
+      <% if post.att_source.present? || post.created_at < RequestContext.community.created_at %>
+        <%= link_to post.att_source, class: 'badge is-tag' do %>
+          <i class="fab fa-stack-exchange" title="Imported <%= post.created_at.iso8601 %>"
+             aria-label="Imported <%= post.created_at.iso8601 %>"></i>
         <% end %>
       <% end %>
     </div>
diff --git a/app/views/posts/_post_tools.html.erb b/app/views/posts/_post_tools.html.erb
index f078a5dca89879329d5b290bf20b2afb8762471f..93e77f24bc17d0f18d94f9dde86fa537e21e77b6 100644
--- a/app/views/posts/_post_tools.html.erb
+++ b/app/views/posts/_post_tools.html.erb
@@ -22,7 +22,7 @@
           <% end %>
         </details>
       <% end %>
-      <% if current_user&.has_privilege?('ChangeCategory') %>
+      <% if current_user&.has_privilege?('ChangeCategory') && (post.question? || post.article?) %>
         <details>
           <summary>Change category</summary>
           <p>
diff --git a/app/views/posts/_type_agnostic.html.erb b/app/views/posts/_type_agnostic.html.erb
index 2ac0c70f6f6e4efeee4fb75ebf373c03a0a9926b..924e342d5e65ad60b745714ef521fde91969d55b 100644
--- a/app/views/posts/_type_agnostic.html.erb
+++ b/app/views/posts/_type_agnostic.html.erb
@@ -7,4 +7,7 @@
     'Article' => 'posts/article_list'
   }
 %>
-<%= render post_types_views[post.post_type.name], post: post %>
\ No newline at end of file
+<% show_type_tag ||= false %>
+<% show_category_tag ||= false %>
+<%= render post_types_views[post.post_type.name], post: post, show_type_tag: show_type_tag,
+           show_category_tag: show_category_tag %>
\ No newline at end of file
diff --git a/app/views/posts/document.html.erb b/app/views/posts/document.html.erb
index 2a5a9e15c93f6f9562048ea969edfccfc173ce45..00e5560a2ee51cad47a051b13a3bd162c0063532 100644
--- a/app/views/posts/document.html.erb
+++ b/app/views/posts/document.html.erb
@@ -7,6 +7,12 @@
   <% end %>
 <% end %>
 
+<% if @post.help_category == '$Moderator' %>
+  <div class="notice is-warning">
+    <i class="fas fa-exclamation-circle"></i> This help article is only available to moderators.
+  </div>
+<% end %>
+
 <h1><%= @post.nil? ? 'This document has not been created yet' : @post.title %></h1>
 <% if @post.nil? %>
   <p>There's meant to be a helpful document here, but the administrator of this site hasn't created it yet.</p>
diff --git a/app/views/questions/_form.html.erb b/app/views/questions/_form.html.erb
index 714c0dbfc45e087ee8d9c01d7405b7fbf3b0af22..7da46a686f349d11ea6e792dc4ba664b09e56ea2 100644
--- a/app/views/questions/_form.html.erb
+++ b/app/views/questions/_form.html.erb
@@ -1,4 +1,3 @@
-<% is_meta ||= false %>
 <% is_edit ||= false %>
 
 <%= render 'posts/markdown_script' %>
@@ -17,16 +16,12 @@
 <%= render 'posts/image_upload' %>
 
 <%= form_for @question, url: is_edit ? edit_question_path(@question) : create_question_path do |f| %>
-  <% if is_meta %>
-    <%= f.hidden_field :category, value: 'Meta' %>
-  <% end %>
-
   <div class="form-group">
     <%= f.label :title, "Title your question:", class: "form-element" %>
     <%= f.text_field :title, class: "form-element" %>
   </div>
 
-  <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body' %>
+  <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body', post: @question %>
 
   <div class="post-preview"></div>
 
@@ -51,22 +46,7 @@
   <% end %>
 
   <div class="form-group">
-    <% button_text = is_meta ? "Ask Meta Question" : "Ask Question" %>
-    <%= f.submit is_edit ? (check_your_post_privilege(@question, 'Edit') ? "Save changes" : "Suggest changes") : button_text, class: "button is-filled" %><br/>
+    <%= f.submit is_edit ? (check_your_post_privilege(@question, 'Edit') ? "Save changes" : "Suggest changes") : 'Ask Question', class: "button is-filled" %>
+    <%= link_to 'Cancel', is_edit ? question_path(@question) : category_path(@question.category), class: 'button is-outlined is-muted' %>
   </div>
-  <% if !is_edit %>
-    <% if is_meta %>
-      <div class="notice">
-        <i class="fas fa-info-circle"></i>
-        Not meant to be asking on Meta? You're in the wrong place - copy your question text and use
-        <%= link_to 'this page', new_question_path %> to ask it instead.
-      </div>
-    <% else %>
-      <div class="notice">
-        <i class="fas fa-info-circle"></i>
-        Want to ask on Meta instead? You're in the wrong place - copy your question text and use
-        <strong><%= link_to 'this page', new_meta_question_path %></strong> to ask it instead.
-      </div>
-    <% end %>
-  <% end %>
 <% end %>
diff --git a/app/views/reports/_tabs.html.erb b/app/views/reports/_tabs.html.erb
index aa68f6b29443d43e572b390b9b6436c26a1a688e..7ab88d9248806e81dd8b0b44b073066d05e386fd 100644
--- a/app/views/reports/_tabs.html.erb
+++ b/app/views/reports/_tabs.html.erb
@@ -1,6 +1,19 @@
-<div class="button-list is-gutterless has-margin-bottom-4">
-  <%= link_to 'Users', users_report_path, class: "button is-muted is-outlined #{current_page?(users_report_path) ? 'is-active' : ''}" %>
-  <%= link_to 'Posts', posts_report_path, class: "button is-muted is-outlined #{current_page?(posts_report_path) ? 'is-active' : ''}" %>
-  <%= link_to 'Subscriptions', subscriptions_report_path,
-              class: "button is-muted is-outlined #{current_page?(subscriptions_report_path) ? 'is-active' : ''}" %>
-</div>
\ No newline at end of file
+<% global = current_page?(global_users_report_path) ||
+            current_page?(global_subs_report_path) ||
+            current_page?(global_posts_report_path) %>
+
+<% if global %>
+  <div class="button-list is-gutterless has-margin-bottom-4">
+    <%= link_to 'Users', global_users_report_path, class: "button is-muted is-outlined #{current_page?(global_users_report_path) ? 'is-active' : ''}" %>
+    <%= link_to 'Posts', global_posts_report_path, class: "button is-muted is-outlined #{current_page?(global_posts_report_path) ? 'is-active' : ''}" %>
+    <%= link_to 'Subscriptions', global_subs_report_path,
+                class: "button is-muted is-outlined #{current_page?(global_subs_report_path) ? 'is-active' : ''}" %>
+  </div>
+<% else %>
+  <div class="button-list is-gutterless has-margin-bottom-4">
+    <%= link_to 'Users', users_report_path, class: "button is-muted is-outlined #{current_page?(users_report_path) ? 'is-active' : ''}" %>
+    <%= link_to 'Posts', posts_report_path, class: "button is-muted is-outlined #{current_page?(posts_report_path) ? 'is-active' : ''}" %>
+    <%= link_to 'Subscriptions', subscriptions_report_path,
+                class: "button is-muted is-outlined #{current_page?(subscriptions_report_path) ? 'is-active' : ''}" %>
+  </div>
+<% end %>
\ No newline at end of file
diff --git a/app/views/reports/posts.html.erb b/app/views/reports/posts.html.erb
index bc02db0bd813465b81b126050af0aa8614398494..1d1a942ff158912458b2820dcfe873f2e989069a 100644
--- a/app/views/reports/posts.html.erb
+++ b/app/views/reports/posts.html.erb
@@ -1,15 +1,15 @@
 <%= render 'tabs' %>
 
 <div class="flex-row">
-  <%= stat_panel 'questions', Question.count %>
-  <%= stat_panel 'answers', Answer.count %>
-  <%= stat_panel 'comments', Comment.count %>
-  <%= stat_panel 'posts this month', Post.where('created_at >= ?', 1.month.ago).count %>
+  <%= stat_panel 'questions', @questions.count %>
+  <%= stat_panel 'answers', @answers.count %>
+  <%= stat_panel 'comments', @comments.count %>
+  <%= stat_panel 'posts this month', @this_month.count %>
 </div>
 
 <h3>New posts</h3>
 <%= line_chart [
-  { name: 'Questions', data: Question.where('created_at >= ?', 1.year.ago).undeleted.group_by_week(:created_at).count },
-  { name: 'Answers', data: Answer.where('created_at >= ?', 1.year.ago).undeleted.group_by_week(:created_at).count },
-  { name: 'Comments', data: Comment.where('created_at >= ?', 1.year.ago).undeleted.group_by_week(:created_at).count },
+  { name: 'Questions', data: @questions.group_by_week(:created_at).count },
+  { name: 'Answers', data: @answers.group_by_week(:created_at).count },
+  { name: 'Comments', data: @comments.group_by_week(:created_at).count },
 ] %>
\ No newline at end of file
diff --git a/app/views/reports/subscriptions.html.erb b/app/views/reports/subscriptions.html.erb
index b9fc73851a3b4dfffb46bc69817e99fb1a242016..eb3a5dede622cc0af888058899da45ee2e384982 100644
--- a/app/views/reports/subscriptions.html.erb
+++ b/app/views/reports/subscriptions.html.erb
@@ -1,10 +1,10 @@
 <%= render 'tabs' %>
 
 <div class="flex-row">
-  <%= stat_panel 'all', Subscription.where(type: 'all').count %>
-  <%= stat_panel 'interesting', Subscription.where(type: 'interesting').count %>
-  <%= stat_panel 'tag', Subscription.where(type: 'tag').count %>
-  <%= stat_panel 'user', Subscription.where(type: 'user').count %>
+  <%= stat_panel 'all', @types.map { |_, v| v }.sum %>
+  <%= stat_panel 'interesting', @types['interesting'] %>
+  <%= stat_panel 'tag', @types['tag'] %>
+  <%= stat_panel 'user', @types['user'] %>
 </div>
 
 <h3>New subscriptions</h3>
diff --git a/app/views/reports/users.html.erb b/app/views/reports/users.html.erb
index 4dfb4154308593ec11434b6218b2c351a7d0ffc7..76345943d745d39c19cf8495ccfb8801cdf676f4 100644
--- a/app/views/reports/users.html.erb
+++ b/app/views/reports/users.html.erb
@@ -14,4 +14,4 @@
 </div>
 
 <h3>New signups</h3>
-<%= line_chart User.where("email NOT LIKE '%localhost'").where('created_at >= ?', 1.year.ago).group_by_week(:created_at).count %>
\ No newline at end of file
+<%= line_chart @users %>
\ No newline at end of file
diff --git a/app/views/search/search.html.erb b/app/views/search/search.html.erb
index 83203611a1628f680c6ea98cfc68e3dfc370571a..25f4fd1091a3e65d0bf792e7c7eaf197ecc4cc90 100644
--- a/app/views/search/search.html.erb
+++ b/app/views/search/search.html.erb
@@ -34,7 +34,7 @@
 
   <div class="item-list has-border-top-width-1 has-border-top-style-solid has-border-color-tertiary-050">
     <% @posts.each do |post| %>
-      <%= render 'posts/list', post: post %>
+      <%= render 'posts/type_agnostic', post: post, show_type_tag: true, show_category_tag: true %>
     <% end %>
   </div>
 <% end %>
diff --git a/app/views/shared/_body_field.html.erb b/app/views/shared/_body_field.html.erb
index b86ec3d576fb1a033cc40034c2680039f5dfb547..df39db82f7c03ee39381a9b61e470ff70c62375b 100644
--- a/app/views/shared/_body_field.html.erb
+++ b/app/views/shared/_body_field.html.erb
@@ -1,9 +1,17 @@
 <div class="form-group">
   <%= f.label field_name, field_label, class: "form-element" %>
   <div class="widget">
+    <% classes = 'form-element post-field js-post-field widget--body h-b-0 h-m-0' %>
+    <% key = "saved_post.#{current_user&.id}.#{request.path}" %>
+    <% saved_at_key = "saved_post_at.#{current_user&.id}.#{request.path}" %>
+    <% saved_at = DateTime.parse(RequestContext.redis.get(saved_at_key) || '') rescue Date.new(2000, 1, 1) %>
+    <% value = RequestContext.redis.exists(key) && RequestContext.redis.exists(saved_at_key) &&
+               (post&.updated_at&.nil? ||
+                (post.updated_at || post.created_at || DateTime.now) <=
+                (saved_at || Date.new(2000, 1, 1))) ?
+                   { value: RequestContext.redis.get(key), class: classes + ' js-draft-loaded' } : {} %>
     <%= render 'shared/markdown_tools' %>
-    <%= f.text_area field_name, { class: 'form-element post-field js-post-field widget--body h-b-0 h-m-0',
-                                  rows: 15, placeholder: 'Start typing your post...' } %>
+    <%= f.text_area field_name, { class: classes, rows: 15, placeholder: 'Start typing your post...' }.merge(value) %>
     <%= render 'posts/mdhint' %>
   </div>
 </div>
\ No newline at end of file
diff --git a/app/views/shared/_markdown_tools.html.erb b/app/views/shared/_markdown_tools.html.erb
index c6b6b06ecfd2bc2ceb25b41979e118f001ef34af..f81d19fcd3afe4327bd6d78d978bfa2564f97ea7 100644
--- a/app/views/shared/_markdown_tools.html.erb
+++ b/app/views/shared/_markdown_tools.html.erb
@@ -1,7 +1,9 @@
 <div class="modal is-with-backdrop" id="markdown-link-insert">
   <div class="modal--container">
     <div class="modal--header">
-      <button class="button is-close-button modal--header-button">&times;</button>
+      <button type="button" class="button is-close-button modal--header-button" data-modal="#markdown-link-insert">
+        &times;
+      </button>
       Link
     </div>
     <div class="modal--body">
@@ -19,7 +21,7 @@
   </div>
 </div>
 
-<div class="markdown-tools widget--header">
+<div class="markdown-tools widget--header js-markdown-tools">
   <div class="button-list is-gutterless">
     <%= md_button action: 'bold', label: 'Bold', class: 'is-icon-only' do %>
       <i class="fas fa-fw fa-bold"></i>
@@ -33,13 +35,13 @@
   </div>
 
   <div class="button-list is-gutterless">
-    <%= md_button label: 'Link', class: 'is-icon-only', data_toggle: 'modal', data_target: '#markdown-link-insert' do %>
+    <%= md_button label: 'Link', class: 'is-icon-only', data_modal: '#markdown-link-insert' do %>
       <i class="fas fa-fw fa-link"></i>
     <% end %>
     <%= md_button action: 'quote', label: 'Block quote', class: 'is-icon-only' do %>
       <i class="fas fa-fw fa-quote-left"></i>
     <% end %>
-    <%= md_button label: 'Insert image', class: 'is-icon-only', data_toggle: 'modal', data_target: '#markdown-image-upload' do %>
+    <%= md_button label: 'Insert image', class: 'is-icon-only', data_modal: '#markdown-image-upload' do %>
       <i class="fas fa-fw fa-images"></i>
     <% end %>
   </div>
diff --git a/app/views/users/_user.html.erb b/app/views/users/_user.html.erb
index 0657efc6ff6f5ade40020b6250a6c9988dc8735f..1001e0caf675dcacea13d2c51e2189c634970901 100644
--- a/app/views/users/_user.html.erb
+++ b/app/views/users/_user.html.erb
@@ -2,9 +2,7 @@
   <div class="flex-row-always">
     <div><img src="<%= avatar_url(user) %>" alt="user avatar" height="32" width="32" /></div>
     <div class="user--meta">
-      <% user_posts = defined?(@post_counts) ? { Question.post_type_id => @post_counts[[user.id, Question.post_type_id]],
-                                                 Answer.post_type_id => @post_counts[[user.id, Answer.post_type_id]] } :
-                                               user.posts.group(:post_type_id).count(:post_type_id) %>
+      <% user_posts = defined?(@post_counts) ? @post_counts[user.id] : user.posts.count %>
       <span class="username"><%= link_to  user_path(user), class: "is-not-underlined" do %>
                                <%= user.username %>
                                <% if user.is_admin &&  SiteSetting['AdminBadgeCharacter'] %>
@@ -14,7 +12,7 @@
                                <% end %>
                              <% end %></span>
       <span class="has-color-tertiary-600 has-font-weight-bold"><%= user.reputation %></span>
-      <span><%= user_posts[Question.post_type_id] || 0 %>Q, <%= user_posts[Answer.post_type_id] || 0 %>A</span>
+      <span><%= pluralize(user_posts, 'post') %></span>
     </div>
   </div>
 </div>
diff --git a/app/views/users/edit_profile.erb b/app/views/users/edit_profile.erb
index 5262aae7ad97157e011fa0833d4674b89c628cc9..bd83d47a21e14b9d88efae5c4e082e6309f49264 100644
--- a/app/views/users/edit_profile.erb
+++ b/app/views/users/edit_profile.erb
@@ -34,7 +34,7 @@
     <%= f.text_field :username, class: 'form-element', autocomplete: 'off' %>
   </div>
 
-  <%= render 'shared/body_field', f: f, field_name: :profile_markdown, field_label: 'Profile' %>
+  <%= render 'shared/body_field', f: f, field_name: :profile_markdown, field_label: 'Profile', post: current_user %>
 
   <div class="post-preview"></div>
 
diff --git a/app/views/users/posts.html.erb b/app/views/users/posts.html.erb
index cef56fa23b4e874c32a0b2cee782a96d0f9fbdd6..3c0419ce5a3120e0d069676df407f9ba348d8cd7 100644
--- a/app/views/users/posts.html.erb
+++ b/app/views/users/posts.html.erb
@@ -1,6 +1,6 @@
-<% content_for :title, "#{params[:type].humanize} by #{@user.username}" %>
+<% content_for :title, "Posts by #{@user.username}" %>
 
-<h1><%= params[:type].humanize %> by <%= link_to @user.username, user_path(@user) %></h1>
+<h1>Posts by <%= link_to @user.username, user_path(@user) %></h1>
 
 <div class="button-list is-gutterless">
   <%= link_to 'Score', query_url(sort: 'score'), class: "button is-muted is-outlined " + (active_search?('score') ? "is-active" : "") %>
@@ -9,7 +9,7 @@
 
 <div class="item-list">
   <% @posts.each do |post| %>
-    <%= render 'posts/list', post: post %>
+    <%= render 'posts/type_agnostic', post: post, show_type_tag: true, show_category_tag: true %>
   <% end %>
 </div>
 
diff --git a/app/views/users/qr_login_code.html.erb b/app/views/users/qr_login_code.html.erb
index 388166d7adc539bcd4e98c7c8ef85311cfb68ac8..d08f9a3251c4fc7f23cff661a304c4b89b1e1148 100644
--- a/app/views/users/qr_login_code.html.erb
+++ b/app/views/users/qr_login_code.html.erb
@@ -1,5 +1,8 @@
 <h1>Mobile Sign In</h1>
-<p>Passwords too much effort? Scan this QR code with your device to sign in there.</p>
+<p>
+  Passwords too much effort? Use a QR code scanning app to scan this code on your device and
+  follow the URL to log in there.
+</p>
 
 <div class="has-text-align-center">
   <svg height="244" width="244">
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb
index 4090c00def4232ddaf0a5c617213acb3f739ef2b..59f651d6f288909b10ff6ea89fa676e5455f26b3 100644
--- a/app/views/users/show.html.erb
+++ b/app/views/users/show.html.erb
@@ -41,14 +41,18 @@
     <h1><%= @user.username %></h1>
     <div class="profile-text">
       <% if @user.profile.nil? || @user.profile.blank? %>
-        <em class="has-color-tertiary-400">A quiet enigma. <%= @user.username %> hasn't written anything about themselves yet.</em>
+        <em class="has-color-tertiary-400">A quiet enigma. We don't know anything about <%= @user.username %> yet.</em>
       <% else %>
         <%= raw(sanitize(@user.profile, scrubber: scrubber)) %>
       <% end %>
       <hr/>
       <span class="has-font-size-caption">
-        <%= link_to flag_history_path(@user.id), class: 'is-muted' do %>
-          <%= pluralize(@user.flags.count, 'flag') %> cast
+        <% if current_user&.id == @user.id || moderator? %>
+          <%= link_to flag_history_path(@user.id), class: 'is-muted' do %>
+            <%= pluralize(@user.flags.count, 'flag') %>
+          <% end %>
+        <% else %>
+          <span class="is-muted"><%= pluralize(@user.flags.count, 'flag') %></span>
         <% end %>
       </span>
     </div>
@@ -92,31 +96,17 @@
 </div>
 
 
-<h2 class="user-profile-heading">Questions</h2>
-<% if @user.questions.size == 0 %>
-  <em class="text-muted"><%= @user.username %> hasn't asked anything yet.</em>
-<% else %>
-  <div class="item-list">
-    <% @user.questions.undeleted.order(score: :desc).first(5).each do |q| %>
-      <%= render 'posts/list', post: q %>
-    <% end %>
-  </div>
-  <%= link_to user_posts_path(@user, type: 'questions'), class: "button is-muted" do %>
-    See all questions &raquo;
-  <% end %>
-<% end %>
-
-<h2 class="user-profile-heading">Answers</h2>
-<% if @user.answers.size == 0 %>
-  <em class="text-muted"><%= @user.username %> hasn't answered anything yet.</em>
+<h2 class="user-profile-heading">Posts</h2>
+<% if @user.posts.size == 0 %>
+  <em class="text-muted"><%= @user.username %> hasn't posted anything yet.</em>
 <% else %>
   <div class="item-list">
-    <% @user.answers.undeleted.order(score: :desc).first(5).each do |a| %>
-      <%= render 'posts/list', post: a %>
+    <% @user.posts.undeleted.order(score: :desc).first(5).each do |a| %>
+      <%= render 'posts/type_agnostic', post: a, show_type_tag: true, show_category_tag: true %>
     <% end %>
   </div>
-  <%= link_to user_posts_path(@user, type: 'answers'), class: "button is-muted" do %>
-    See all answers &raquo;
+  <%= link_to user_posts_path(@user), class: "button is-muted" do %>
+    See all &raquo;
   <% end %>
 <% end %>
 
diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb
index 907adb6847a75286c3f643ae530945a21f1efeb0..56b2be704889ea2a551094b6262b140a0838e784 100644
--- a/config/initializers/session_store.rb
+++ b/config/initializers/session_store.rb
@@ -1,3 +1,7 @@
 # Be sure to restart your server when you modify this file.
 
-Rails.application.config.session_store :cookie_store, key: '_qpixel_session'
+if Rails.env.production?
+  Rails.application.config.session_store :cookie_store, key: 'codidact_acct', domain: :all
+else
+  Rails.application.config.session_store :cookie_store, key: 'codidact_acct'
+end
diff --git a/config/routes.rb b/config/routes.rb
index 9d69cd0344a2e71e112aa680ed270bd1288c4ec5..6f84a49bd62dd24e4c105f9fee4f2d37f94e26f5 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -17,6 +17,8 @@ Rails.application.routes.draw do
   get    'admin/privileges',               to: 'admin#privileges', as: :admin_privileges
   get    'admin/privileges/:name',         to: 'admin#show_privilege', as: :admin_privilege
   post   'admin/privileges/:name',         to: 'admin#update_privilege', as: :admin_update_privilege
+  get    'admin/mod-email',                to: 'admin#admin_email', as: :moderator_email
+  post   'admin/mod-email',                to: 'admin#send_admin_email', as: :send_moderator_email
 
   get    'close_reasons',                  to: 'close_reasons#index', as: :close_reasons
   get    'close_reasons/edit/:id',         to: 'close_reasons#edit', as: :close_reason
@@ -79,6 +81,8 @@ Rails.application.routes.draw do
   get    'posts/:id/history',              to: 'post_history#post', as: :post_history
   get    'posts/search',                   to: 'search#search', as: :search
   post   'posts/upload',                   to: 'posts#upload', as: :upload
+  post   'posts/save-draft',               to: 'posts#save_draft', as: :save_draft
+  post   'posts/delete-draft',             to: 'posts#delete_draft', as: :delete_draft
 
   get    'posts/:id/edit',                 to: 'posts#edit', as: :edit_post
   patch  'posts/:id/edit',                 to: 'posts#update', as: :update_post
@@ -119,7 +123,7 @@ Rails.application.routes.draw do
   get    'users/:id',                      to: 'users#show', as: :user
   get    'users/:id/flags',                to: 'flags#history', as: :flag_history
   get    'users/:id/mod',                  to: 'users#mod', as: :mod_user
-  get    'users/:id/posts/:type',          to: 'users#posts', as: :user_posts
+  get    'users/:id/posts',                to: 'users#posts', as: :user_posts
   get    'users/me/notifications',         to: 'notifications#index', as: :notifications
   get    'users/edit/profile',             to: 'users#edit_profile', as: :edit_user_profile
   patch  'users/edit/profile',             to: 'users#update_profile', as: :update_user_profile
@@ -179,18 +183,24 @@ Rails.application.routes.draw do
     get    ':id/feed',                             to: 'categories#rss_feed', as: :category_feed
   end
 
-  get   'warning',                       to: 'mod_warning#current', as: :current_mod_warning
-  post  'warning/approve',               to: 'mod_warning#approve', as: :current_mod_warning_approve
-  get   'warning/log/:user_id',          to: 'mod_warning#log', as: :mod_warning_log
-  get   'warning/new/:user_id',          to: 'mod_warning#new', as: :new_mod_warning
-  post  'warning/new/:user_id',          to: 'mod_warning#create', as: :create_mod_warning
+  get   'warning',                         to: 'mod_warning#current', as: :current_mod_warning
+  post  'warning/approve',                 to: 'mod_warning#approve', as: :current_mod_warning_approve
+  get   'warning/log/:user_id',            to: 'mod_warning#log', as: :mod_warning_log
+  get   'warning/new/:user_id',            to: 'mod_warning#new', as: :new_mod_warning
+  post  'warning/new/:user_id',            to: 'mod_warning#create', as: :create_mod_warning
 
-  get   'uploads/:key',                  to: 'application#upload', as: :uploaded
-  get   'dashboard',                     to: 'application#dashboard', as: :dashboard
+  get   'uploads/:key',                    to: 'application#upload', as: :uploaded
 
-  get   '403',                           to: 'errors#forbidden'
-  get   '404',                           to: 'errors#not_found'
-  get   '409',                           to: 'errors#conflict'
-  get   '422',                           to: 'errors#unprocessable_entity'
-  get   '500',                           to: 'errors#internal_server_error'
+  scope 'dashboard' do
+    root                                   to: 'application#dashboard', as: :dashboard
+    get 'reports',                         to: 'reports#users_global', as: :global_users_report
+    get 'reports/subscriptions',           to: 'reports#subs_global', as: :global_subs_report
+    get 'reports/posts',                   to: 'reports#posts_global', as: :global_posts_report
+  end
+
+  get   '403',                             to: 'errors#forbidden'
+  get   '404',                             to: 'errors#not_found'
+  get   '409',                             to: 'errors#conflict'
+  get   '422',                             to: 'errors#unprocessable_entity'
+  get   '500',                             to: 'errors#internal_server_error'
 end
diff --git a/db/migrate/20200625115618_add_mod_tags_join_table.rb b/db/migrate/20200625115618_add_mod_tags_join_table.rb
new file mode 100644
index 0000000000000000000000000000000000000000..11c52a5a7759c208f6822737a185ce4a4df34ba6
--- /dev/null
+++ b/db/migrate/20200625115618_add_mod_tags_join_table.rb
@@ -0,0 +1,8 @@
+class AddModTagsJoinTable < ActiveRecord::Migration[5.2]
+  def change
+    create_table :categories_moderator_tags, id: false, primary_key: [:category_id, :tag_id] do |t|
+      t.bigint :category_id
+      t.bigint :tag_id
+    end
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 587277eb5165bf3d3104f36ab94ae9f7f12478c2..78959a5a2a15d4f48ba07b41979767fe775bfeab 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2020_06_18_124645) do
+ActiveRecord::Schema.define(version: 2020_06_25_115618) do
 
   create_table "active_storage_attachments", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", force: :cascade do |t|
     t.string "name", null: false
@@ -56,6 +56,11 @@ ActiveRecord::Schema.define(version: 2020_06_18_124645) do
     t.index ["tag_set_id"], name: "index_categories_on_tag_set_id"
   end
 
+  create_table "categories_moderator_tags", id: false, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", force: :cascade do |t|
+    t.bigint "category_id"
+    t.bigint "tag_id"
+  end
+
   create_table "categories_post_types", id: false, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", force: :cascade do |t|
     t.bigint "category_id", null: false
     t.bigint "post_type_id", null: false
diff --git a/db/seeds/posts.yml b/db/seeds/posts.yml
index a772b359464e0ea81534d496f212bdcbc8cc4f64..61e95e70a032e6ecb373cf58dbdf4319ef2d5f32 100644
--- a/db/seeds/posts.yml
+++ b/db/seeds/posts.yml
@@ -42,4 +42,11 @@
   body_markdown: $FILE posts/formatting.html
   body: $FILE posts/formatting.html
   doc_slug: formatting
-  help_category: Site Information
\ No newline at end of file
+  help_category: Site Information
+
+- post_type_id: <%= HelpDoc.post_type_id %>
+  title: So you're a moderator now
+  body_markdown: $FILE posts/moderator.html
+  body: $FILE posts/moderator.html
+  doc_slug: moderator
+  help_category: $Moderator
diff --git a/db/seeds/posts/moderator.html b/db/seeds/posts/moderator.html
new file mode 100644
index 0000000000000000000000000000000000000000..3f23081109c6c1aff00b64cd2f15c684c84c1388
--- /dev/null
+++ b/db/seeds/posts/moderator.html
@@ -0,0 +1,22 @@
+<h1 id="so-you-re-a-moderator-now">So you&#39;re a moderator now</h1>
+<p>So you&#39;ve been given moderator status on a Codidact site. Welcome! This is what that means for you.</p>
+<h2 id="your-community-is-your-community">Your community is your community</h2>
+<p>Codidact sites are community-run. Codidact is here to support you and your community, but you make the decisions about how you run things yourselves. We&#39;ll provide our advice and guidance and let you know what we think works well, but if your community wants to go in a different direction, that&#39;s up to you!</p>
+<p>All of our communities must abide by the Codidact Code of Conduct, which is <a href="/policy/code-of-conduct">in your help center</a>.</p>
+<h2 id="you-re-a-community-leader">You&#39;re a community leader</h2>
+<p>Particularly in the early stages of your site, you&#39;ll be one of the most prominent figures. Your actions will help to build and shape your site and your community, and set the standards and norms for those who come after you.</p>
+<p>One of the most important things you can do for your community is to help guide discussion. Remember that being a moderator doesn&#39;t make you More Right™, but it does mean that your words carry a certain weight and your community looks to you as a guiding figure. Offer your experience and advice, but avoid throwing your weight around and using your position to shut down people who disagree with you.</p>
+<p>This also means that your actions can set the tone for the community - if you&#39;re welcoming and inclusive and you encourage others to do likewise, your community will be welcoming and inclusive too.</p>
+<h3 id="we-ll-listen-to-you">We&#39;ll listen to you</h3>
+<p>Codidact staff keep an eye on all communities, but we don&#39;t always see every post. If there&#39;s something you think needs our attention or something your community needs that we can help with, let someone know - you can email us at info@codidact.org, or we&#39;ll often respond more quickly in Discord (see below). As a leader in your community, you know your community better than we do, so we&#39;ll defer to you as much as possible.</p>
+<h2 id="you-get-access-to-moderator-only-chat-and-news">You get access to moderator-only chat and news</h2>
+<p>Every community has a link to our <a href="https://discord.gg/bv2aaGa">Communities Discord server</a>, where each community can have chat channels. As a moderator, you also get access to the moderator-only lounge, where moderators from across our network and Codidact staff can talk in private. If you haven&#39;t already, create an account there (Discord accounts are separate to Codidact network accounts), and ping someone with the Admin or Team role to get your access.</p>
+<p>We also have a subscription available for moderators. We&#39;ll occasionally send round newsletters containing important or useful information for what you do as a moderator. You&#39;ll only get these emails if you subscribe, which you can do <a href="/subscriptions/new/moderators">here</a> (the frequency you pick doesn&#39;t matter, as this list is sent to manually).</p>
+<h2 id="you-get-some-special-abilities">You get some special abilities</h2>
+<p>As a moderator, you automatically have every privilege on the site. You can also do a few more things that regular users never get to do:</p>
+<ul>
+    <li>Handle flags. This is one of your main jobs - responding to stuff that your community has identified as needing your attention.</li>
+    <li>Editing your <a href="/help">help center</a>. You can edit existing articles and create new articles. In particular, you should edit your <a href="/help/faq">site FAQ</a> with some more relevant information about your site for new users.</li>
+</ul>
+<hr>
+<p>Welcome aboard! If you have any questions, just ask someone - Codidact staff monitor <a href="https://meta.codidact.com">Meta</a>, or you can use our Communities or Codidact Discord servers.</p>
diff --git a/public/assets/community/judaism.css b/public/assets/community/judaism.css
new file mode 100644
index 0000000000000000000000000000000000000000..d882321f46d14d3e3dfe0ec9cf1928296ce54d3b
--- /dev/null
+++ b/public/assets/community/judaism.css
@@ -0,0 +1,5 @@
+@import url('https://fonts.googleapis.com/css2?family=Frank+Ruhl+Libre&display=swap');
+
+.post--body > *:not(div) {
+    font-family: 'Frank Ruhl Libre', serif;
+}
diff --git a/public/assets/community/judaism.js b/public/assets/community/judaism.js
new file mode 100644
index 0000000000000000000000000000000000000000..913e7ec27adc21c12161436d9eac7931055a1943
--- /dev/null
+++ b/public/assets/community/judaism.js
@@ -0,0 +1,261 @@
+/*
+The MIT License (MIT)
+Copyright (c) 2013  HodofHod (https://github.com/HodofHod, http://judaism.stackexchange.com/users/883/hodofhod)
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+*/
+
+// Thanks: @Manishearth for the inject() function, and James Montagne for the draggability.
+// Thanks to all who've helped debug and discuss, especially the Mac users, nebech.
+
+(function HBKeyboard() {
+  var docCookies = { //from developer.mozilla.org/en-US/docs/Web/API/document.cookie
+    getItem: function (sKey) {
+      return unescape(document.cookie.replace(new RegExp("(?:(?:^|.*;)\\s*" + escape(sKey).replace(/[\-\.\+\*]/g, "\\$&") + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1")) || null;
+    },
+    setItem: function (sKey, sValue) {
+      if (!sKey || /^(?:expires|max\-age|path|domain|secure)$/i.test(sKey)) {
+        return false;
+      }
+      document.cookie = escape(sKey) + "=" + escape(sValue) + "; expires=Fri, 31 Dec 9999 23:59:59 GMT; domain=stackexchange.com; path=/";
+      return true;
+    },
+    hasItem: function (sKey) {
+      return (new RegExp("(?:^|;\\s*)" + escape(sKey).replace(/[\-\.\+\*]/g, "\\$&") + "\\s*\\=")).test(document.cookie);
+    },
+  };
+
+  var currentTextfield = $('textarea, input[type=text]');
+  $(document).ready(function(){
+    $(document).on('focus', 'textarea, input[type=text]', function(){
+      currentTextfield = $(this);
+    });
+
+    var wh = $(window).height(),
+      ww = $(window).width(),
+      kb = createKeyboard().hide();
+    $('#hbk-toggle span').css({
+      'padding': '3px',
+      'text-align': 'center',
+      'background-image': "none",
+      'font-weight':'bolder'
+    });
+    $(window).resize(function(){
+      kb.css({
+        top: '+=' + ($(window).height() - wh) + 'px',
+        left: '+=' + ($(window).width() - ww) + 'px',
+      });
+      if (kb.css('top') < '0px') kb.css('top','0px');
+      wh = $(window).height();
+      ww = $(window).width();
+    });
+
+    QPixel.addEditorButton(`א`, 'Hebrew Keyboard', () => {
+      kb.toggle();
+    });
+  });
+
+  function createKeyboard() {
+    var stand = "קראטוןםפשדגכעיחלךףזסבהנמצתץ",
+      alpha = "חזוהדגבאסןנםמלךכיטתשרקץצףפע",
+      nek = ["שׁ", "שׂ", "וְ", "וֱ", "וֲ", "וֳ", "וִ", "וֵ", "וֶ", "וַ", "וָ", "וֹ", "וֻ", "וּ"],
+      x = 50,
+      y = 50,
+      kb = $('<div class="hbkeyboard"></div>').appendTo($("body"));
+
+    $.each(alpha.split('').concat(nek), function (i, letter) {
+      kb.append('<button type="button" class="hbkey" data-t="' + letter.slice(-1) + '">' + letter + '</button>');
+    });
+
+    kb.children('button:lt(8)').wrapAll('<div class="first kbrow">');
+    kb.children('button:lt(10)').wrapAll('<div class="second kbrow">');
+    kb.children('button:lt(9)').wrapAll('<div class="third kbrow">');
+    kb.children('button:lt(14)').wrapAll('<div class="fourth kbrow">');
+    //kb.children('.first.kbrow').prepend('<button type="button" class="hbkey" data-t="&rlm;">&amp;rlm;</button>');
+    kb.prepend('<div style="position:relative; height:20px;margin-bottom: 10px;"><button type="button" id="setbutton" data-t="">Settings</button><button type="button" id="closebutton" data-t="">x</button></div>');
+    kb.prepend('<span style="position:absolute; top:0; right:0; color:transparent">בס"ד</span>');
+    kb.append('<div class="inserts"></div>');
+    kb.children('.inserts').append('<button type="button" class="hbins" data-t="&rlm;">Ins RLM</button>');
+    kb.children('.inserts').append('<button type="button" class="hbins" data-t="&lrm;">Ins LRM</button>');
+    $('<div class="kbsettings" style="text-align:left;"><div><input type="checkbox" id="keylayout">Use standard layout</div><div><input type="checkbox" id="rlm">Insert &amp;rlm; as text (posts only)</div></div>').appendTo(kb).hide();
+
+    /* CSS For Keyboard and buttons */
+    $('html > head').append($('<style>.hbkey:active{border: 1px solid lightgray !important;}</style>'));
+    $('.kbsettings input').css('margin','5px');
+    kb.css({
+      position: 'fixed',
+      border: '1px solid #BBB',
+      padding: '1em',
+      'padding-left': '1.5em',
+      left: x,
+      top: y,
+      direction: 'ltr',
+      'border-radius': '0.2em',
+      'z-index': '99999',
+      'background-color': 'rgb(241, 241, 241)'
+    });
+    $('.kbrow').css({
+      position: 'relative',
+      'white-space': 'nowrap',
+      'text-align': 'right'
+    });
+    $('.zeroth, .first, .third').css({
+      right: '20px'
+    });
+    $('.second').css('right', '10px');
+    $('.fourth').css({ //nekudos row
+      'text-align': 'center'
+    });
+    $('.hbkey').css({
+      margin: '1px',
+      display: 'inline-block',
+      width: '32px',
+      border: 'none',
+      height: '31px',
+      padding: 0,
+      color: "#000",
+      'text-shadow': "none",
+      'font-family': 'FrankRuehl, New Peninim MT, Arial, sans-serif',
+      'font-size': '20px',
+      'vertical-align': 'top',
+      'box-shadow': '1px 1px 2px 1px gray',
+      'background':'inherit',//Mac Chrome
+      'background-color':'inherit',//Mac Chrome
+    });
+    $('.fourth.kbrow .hbkey').css({
+      direction:'rtl',
+      padding: '0',
+      width: '20px',
+      'font-size': '25px',
+      'min-height': '33px'
+    });
+    $('.hbins').css({
+      'margin-top': '0.5em',
+      'margin-right': '0.5em'
+    });
+
+
+    /* Event handling for buttons and checkboxes*/
+    kb.find('.hbkey').click(function () {
+      t = currentTextfield[0];
+      var start = t.selectionStart,
+        end = t.selectionEnd,
+        text = t.value,
+        chr = $(this).data('t');
+
+      if (chr === '‏' && $('#rlm').is(':checked') && t.id !== 'input') chr = '&rlm;';//special case for rlm.
+      var res = text.slice(0, start) + chr + text.slice(end),
+        len = chr.length;
+      $(t).val(res).trigger('input').focus();
+      t.setSelectionRange(start + len, start + len);
+    });
+
+    kb.find('.hbins').click(function () {
+      t = currentTextfield[0];
+      var start = t.selectionStart,
+        end = t.selectionEnd,
+        text = t.value,
+        chr = $(this).data('t');
+      var res = text.slice(0, start) + chr + text.slice(end),
+        len = chr.length;
+      $(t).val(res).trigger('input').focus();
+      t.setSelectionRange(start + len, start + len);
+    });
+
+    $('#setbutton, #closebutton')
+    .css({
+      border: 'none',
+      background: 'transparent',
+      position: 'absolute',
+      top: 0,
+      color: "#000",
+      'text-shadow': 'none',
+      'font-size': '14px',
+      'font-family': 'FrankRuehl, New Peninim MT, Arial, sans-serif',
+    }).off();
+
+    $('#setbutton') //Settings button
+    .css('left',0)
+    .click(function () {
+      $(this).text($(this).text() === "Settings" ? "Keyboard" : "Settings");
+      $('.first, .second, .third, .fourth').slideToggle();
+      $('.kbsettings').slideToggle();
+    });
+
+    $('#closebutton')//x button
+    .css('right',0)
+    .click(function () {
+      kb.fadeToggle('medium');
+    });
+
+    $('#keylayout').change(function(){
+      var layout = $(this).prop('checked') ? stand : alpha;
+      $('.hbkey').slice(0, 27).each(function (index) {
+        $(this).data('t', layout[index]).text(layout[index]);
+      });
+      docCookies.setItem('layoutSetting', $('#keylayout').prop('checked'));
+    });
+
+    /*$('#rlm').change(function(){
+      docCookies.setItem('rlmSetting', $('#rlm').prop('checked'));
+    });
+
+    $('#rlm').prop('checked', docCookies.getItem('rlmSetting') === "true" ?  true : false).change();*/
+    $('#keylayout').prop('checked', docCookies.getItem('layoutSetting') === "true" ? true : false).change();
+    return kb;
+  }
+
+
+
+  //Draggability
+  var drag = {
+      elem: null,
+      x: 0,
+      y: 0,
+      state: false
+    },
+    delta = {
+      x: 0,
+      y: 0
+    };
+  $(document).on('mousedown', '.hbkeyboard', function (e) {
+    if (!drag.state) {
+      drag.elem = this;
+      drag.x = e.pageX;
+      drag.y = e.pageY;
+      drag.state = true;
+    }
+    return false;
+  });
+  $(document).mousemove(function (e) {
+    if (drag.state) {
+      delta.x = e.pageX - drag.x;
+      delta.y = e.pageY - drag.y;
+      var cur_offset = $(drag.elem).offset();
+
+      $(drag.elem).offset({
+        left: (cur_offset.left + delta.x),
+        top: (cur_offset.top + delta.y)
+      });
+      if ($(drag.elem).css('top') < '0px') $(drag.elem).css('top','0px');
+      drag.x = e.pageX;
+      drag.y = e.pageY;
+    }
+  });
+  $(document).mouseup(function () {
+    drag.state && (drag.state = false);
+  });
+})();
\ No newline at end of file
diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb
index 5dfaac7cd40bd9f94fab0170556dfd3b075f8abf..91737e259b8be94d27bf858bf5371df4159c9a86 100644
--- a/test/controllers/users_controller_test.rb
+++ b/test/controllers/users_controller_test.rb
@@ -147,15 +147,15 @@ class UsersControllerTest < ActionController::TestCase
     assert_equal 'standard_user', assigns(:user).twitter
   end
 
-  test 'should get full questions list for a user' do
-    get :posts, params: { id: users(:standard_user).id, type: 'questions' }
+  test 'should get full posts list for a user' do
+    get :posts, params: { id: users(:standard_user).id }
     assert_response 200
     assert_not_nil assigns(:user)
     assert_not_nil assigns(:posts)
   end
 
-  test 'should get full questions list in JSON format' do
-    get :posts, params: { id: users(:standard_user).id, type: 'questions', format: 'json' }
+  test 'should get full posts list in JSON format' do
+    get :posts, params: { id: users(:standard_user).id, format: 'json' }
     assert_response 200
     assert_not_nil assigns(:user)
     assert_not_nil assigns(:posts)
@@ -164,43 +164,8 @@ class UsersControllerTest < ActionController::TestCase
     end
   end
 
-  test 'should get full answers list for a user' do
-    get :posts, params: { id: users(:standard_user).id, type: 'answers' }
-    assert_response 200
-    assert_not_nil assigns(:user)
-    assert_not_nil assigns(:posts)
-  end
-
-  test 'should get full answers list in JSON format' do
-    get :posts, params: { id: users(:standard_user).id, type: 'answers', format: 'json' }
-    assert_response 200
-    assert_not_nil assigns(:user)
-    assert_not_nil assigns(:posts)
-    assert_nothing_raised do
-      JSON.parse(response.body)
-    end
-  end
-
-  test 'should reject invalid post type for full list' do
-    get :posts, params: { id: users(:standard_user).id, type: 'invalid' }
-    assert_response 400
-    assert_not_nil assigns(:user)
-    assert_nil assigns(:posts)
-  end
-
-  test 'should reject invalid post type for full list and return JSON' do
-    get :posts, params: { id: users(:standard_user).id, type: 'invalid', format: 'json' }
-    assert_response 400
-    assert_not_nil assigns(:user)
-    assert_nil assigns(:posts)
-    assert_nothing_raised do
-      JSON.parse(response.body)
-    end
-    assert_equal 'invalid', JSON.parse(response.body)['status']
-  end
-
   test 'should sort full posts lists correctly' do
-    get :posts, params: { id: users(:standard_user).id, type: 'questions', format: :json, sort: 'age' }
+    get :posts, params: { id: users(:standard_user).id, format: :json, sort: 'age' }
     assert_response 200
     assert_not_nil assigns(:user)
     assert_not_nil assigns(:posts)
diff --git a/test/helpers/search_helper_test.rb b/test/helpers/search_helper_test.rb
index a1e656d94146e57a4b55122fcacac539fb7d6449..094b8c7703ffafec6a3131b3b9ca7d66a8f3e4f2 100644
--- a/test/helpers/search_helper_test.rb
+++ b/test/helpers/search_helper_test.rb
@@ -37,7 +37,7 @@ class SearchHelperTest < ActionView::TestCase
   end
 
   test 'qualifiers_to_sql should return a correct SQL string' do
-    assert_equal 'score = 1 AND created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 2 WEEK)',
+    assert_equal 'score = 1.0 AND created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 2 WEEK)',
                  qualifiers_to_sql(['score:1', 'created:>=2w'])
     assert_equal '', qualifiers_to_sql([])
   end
diff --git a/test/mailers/admin_mailer_test.rb b/test/mailers/admin_mailer_test.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4121d891fb5ff30c92194342af54fefd48353790
--- /dev/null
+++ b/test/mailers/admin_mailer_test.rb
@@ -0,0 +1,7 @@
+require 'test_helper'
+
+class AdminMailerTest < ActionMailer::TestCase
+  # test "the truth" do
+  #   assert true
+  # end
+end
diff --git a/test/mailers/previews/admin_mailer_preview.rb b/test/mailers/previews/admin_mailer_preview.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b3f5edda0b7de4c0ceb60d3b98aecc4c0e022e75
--- /dev/null
+++ b/test/mailers/previews/admin_mailer_preview.rb
@@ -0,0 +1,3 @@
+# Preview all emails at http://localhost:3000/rails/mailers/admin_mailer
+class AdminMailerPreview < ActionMailer::Preview
+end