diff --git a/app/assets/stylesheets/moderators.scss b/app/assets/stylesheets/moderators.scss
index 0d7e54e2a..7052db7dc 100644
--- a/app/assets/stylesheets/moderators.scss
+++ b/app/assets/stylesheets/moderators.scss
@@ -2,163 +2,87 @@
@import 'mixins';
@import 'config/import';
-.mod-index-hero {
- margin: 0 auto;
- padding: var(--su-7) var(--su-1);
- text-align: center;
- background: var(--body-bg);
- h1 {
- font-size: calc(1.8em + 2vw);
- }
- h2 {
- font-size: calc(0.9em + 1vw);
- }
- a {
- text-decoration: underline;
- }
- .mod-index-hero-data-pod {
- display: inline-block;
- margin: 10px;
- width: 90%;
- font-weight: bold;
- border: 3px solid var(--body-color);
- padding: 20px 0px;
- .mod-index-hero-data-pod-number {
- font-size: 2.8em;
- }
- @media screen and (min-width: 960px) {
- width: calc(190px + 11vw);
- padding: 80px 0px;
- margin: 15px 2px;
- .mod-index-hero-data-pod-number {
- font-size: calc(2.7em + 0.2vw);
- }
- }
- @media screen and (min-width: 1300px) {
- width: calc(260px + 8vw);
- padding: 80px 0px;
- margin: 30px;
- .mod-index-hero-data-pod-number {
- font-size: calc(2.8em + 0.5vw);
- }
- }
- }
- .mod-index-hero-sub {
- font-size: 0.9em;
- }
+.mod-center-heading {
+ margin-bottom: var(--su-6);
}
-.mod-index-header {
- margin: 50px auto 30px;
- width: 96%;
- max-width: 1600px;
- padding-left: 20px;
- .mod-index-header-top-filters {
- padding: 10px 0px 25px;
- a {
- color: var(--body-color);
- font-size: 0.86em;
- padding: 3px 18px;
- border-radius: 100px;
- border: 1px solid transparent;
- &.mod-index-top-filter-link--active {
- border: 1px solid var(--body-color);
- }
- }
- }
- a.mod-index-header-link {
- color: $black;
- padding: 5px 15px;
- border-radius: 3px;
- font-size: 1.5em;
- font-weight: bold;
- border: 1px solid transparent;
- }
- a.mod-index-header-link--active {
- @include themeable(border, theme-border, 1px solid $black);
- }
-}
+.mod-index-container {
+ .mod-index-header {
+ background-color: var(--base-inverted);
+ border-radius: var(--radius) var(--radius) 0 0;
+ border-bottom: 1px solid var(--base-10);
+ padding: var(--su-6) var(--su-4);
-.mod-index-list {
- margin: 5px auto;
- width: 96%;
- max-width: 1600px;
- border-radius: 3px;
- border: 1px solid var(--card-color-tertiary);
- background: var(--card-bg);
-
- .mod-index-row {
- padding: 10px;
- @include themeable(
- border-bottom,
- theme-border,
- 1px solid $light-medium-gray
- );
- a.mod-index-article-title {
- font-size: calc(1.2em + 0.3vw);
- font-weight: bold;
- margin-right: 6px;
- display: inline-block;
- color: var(--body-color);
+ .tag-view {
+ color: var(--base-90);
}
- .mod-index-article-tagslist {
- display: inline-block;
+ }
+ .mod-index-list-header {
+ display: grid;
+ grid-template-columns: 4fr 2fr 1fr;
+ background-color: var(--base-0);
+ border: solid var(--base-10);
+ border-width: 1px 0;
+ h2 {
+ padding: var(--su-2) var(--su-4);
+ color: var(--base-50);
}
- a.mod-index-article-tag {
- font-size: 0.9em;
- padding: 3px 8px;
- border-radius: 3px;
- background: $light-gray;
- color: $black;
- vertical-align: 0.2em;
- }
- .mod-index-actions-and-details {
- img {
- height: 1.4em;
- width: 1.4em;
- border-radius: 100px;
- margin-right: 5px;
- display: inline-block;
- vertical-align: -0.3em;
- }
- a {
- font-weight: 500;
- color: var(--card-color-tertiary);
- }
- }
- .quick-mod-button {
- font-size: 0.95em;
- padding: 5px 18px;
- border-radius: 100px;
- @include themeable(border, theme-border, 1px solid $medium-gray);
- display: inline-block;
- margin: 10px 0px 5px;
- margin-right: 5px;
- background: transparent;
- color: var(--body-color);
- }
- .mod-index-row-iframes {
- padding: 5px 0px;
- display: flex;
- iframe {
- height: calc(100vh - 100px);
- max-height: 800px;
+ }
+ .mod-index-list {
+ margin: 0;
+ width: 100%;
+ .moderation-articles-list {
+ .moderation-single-article {
width: 100%;
- border: 0px;
- display: inline-block;
- }
- .actions-panel {
- width: 36%;
+ background-color: var(--base-inverted);
+ text-align: left;
+ padding: var(--su-4);
+ border: solid var(--base-10);
+ border-width: 1px 0;
+ display: grid;
+ grid-template-columns: 4fr 2fr 1fr;
+ .mod-article-tag {
+ color: var(--base-80);
+ padding-right: var(--su-2);
+ .article-hash-tag {
+ color: var(--base-40);
+ }
+ }
+ .article-author {
+ color: var(--base-80);
+ padding-left: var(--su-4);
+ }
+ .article-published-at {
+ color: var(--base-90);
+ padding-left: var(--su-6);
+ }
+ .article-iframes-container {
+ border-radius: var(--radius);
+ margin-top: var(--su-3);
+ grid-column-start: 1;
+ grid-column-end: 4;
+ width: 100%;
+ display: flex;
+ &.opened {
+ border: 1px solid var(--base-20);
+ }
+ iframe {
+ border: none;
+ }
+ .article-iframe {
+ width: 100%;
+ height: 650px; // height is arbitrary, will be removed for modal-esque view
+ }
+ .actions-panel-iframe {
+ width: 60%;
+ height: 650px; // height is arbitrary, will be removed for modal-esque view
+ }
+ }
}
}
}
}
-.mod-index-footer {
- text-align: center;
- padding: 30px 10px;
-}
-
// these styles are to correct height and margin issues in the iframe
.moderations-actions_panel {
min-height: initial;
@@ -763,3 +687,29 @@
padding: 3px 8px;
border-radius: 3px;
}
+
+.mod-center-section-title {
+ padding: var(--su-2);
+
+ &.resources {
+ margin-top: var(--su-7);
+ }
+}
+
+.mod-center-inbox {
+ .inbox-tags {
+ display: flex;
+ justify-content: space-between;
+ padding-left: var(--su-3);
+ padding-right: var(--su-3);
+ }
+}
+
+#mod-center-other-options svg {
+ width: var(--su-3);
+}
+
+.mod-feedback {
+ margin-top: var(--su-6);
+ margin-left: var(--su-3);
+}
diff --git a/app/controllers/moderations_controller.rb b/app/controllers/moderations_controller.rb
index 462577f1c..d313bf2b1 100644
--- a/app/controllers/moderations_controller.rb
+++ b/app/controllers/moderations_controller.rb
@@ -1,17 +1,26 @@
class ModerationsController < ApplicationController
after_action :verify_authorized
+ JSON_OPTIONS = {
+ only: %i[id title published_at cached_tag_list path],
+ include: {
+ user: { only: %i[username name path articles_count] }
+ }
+ }.freeze
+
def index
skip_authorization
return unless current_user&.trusted
- @articles = Article.published.
+ articles = Article.published.
where("score > -5 AND score < 5").
order("published_at DESC").limit(70)
- @articles = @articles.cached_tagged_with(params[:tag]) if params[:tag].present?
- @articles = @articles.where("nth_published_by_author > 0 AND nth_published_by_author < 4 AND published_at > ?", 7.days.ago) if params[:state] == "new-authors"
- @articles = @articles.decorate
+ articles = articles.cached_tagged_with(params[:tag]) if params[:tag].present?
+ articles = articles.where("nth_published_by_author > 0 AND nth_published_by_author < 4 AND published_at > ?", 7.days.ago) if params[:state] == "new-authors"
+ @articles = articles.includes(:user).to_json(JSON_OPTIONS)
@tag = Tag.find_by(name: params[:tag]) || not_found if params[:tag].present?
+ @current_user_tags = current_user.moderator_for_tags
+ @community_mod_channel = current_user.chat_channels.find_by("channel_name LIKE ?", "Community Mods: Team%")
end
def article
diff --git a/app/javascript/actionsPanel/initializeActionsPanelToggle.js b/app/javascript/actionsPanel/initializeActionsPanelToggle.js
index 79c2ff3e6..4cb7771a4 100644
--- a/app/javascript/actionsPanel/initializeActionsPanelToggle.js
+++ b/app/javascript/actionsPanel/initializeActionsPanelToggle.js
@@ -24,7 +24,8 @@ export default function initializeActionsPanel(user, path) {
document.querySelector('.mod-actions-menu').innerHTML = modActionsMenuHTML;
// eslint-disable-next-line no-restricted-globals
- if (!top.document.location.pathname.endsWith('/mod')) {
+ if (!top.document.location.pathname.includes('/mod')) {
+ // don't show mod button in mod center page
document.getElementById(
'mod-actions-menu-btn-area',
).innerHTML = modActionsMenuIconHTML;
diff --git a/app/javascript/modCenter/moderationArticles.jsx b/app/javascript/modCenter/moderationArticles.jsx
new file mode 100644
index 000000000..455b804b3
--- /dev/null
+++ b/app/javascript/modCenter/moderationArticles.jsx
@@ -0,0 +1,40 @@
+import { h, Component } from 'preact';
+import SingleArticle from './singleArticle';
+
+export class ModerationArticles extends Component {
+ state = {
+ articles: JSON.parse(
+ document.getElementById('mod-index-list').dataset.articles,
+ ),
+ };
+
+ render() {
+ const { articles } = this.state;
+
+ return (
+
+ {articles.map((article) => {
+ const {
+ id,
+ title,
+ path,
+ cached_tag_list: cachedTagList,
+ published_at: publishedAt,
+ user,
+ } = article;
+ return (
+
+ );
+ })}
+
+ );
+ }
+}
diff --git a/app/javascript/modCenter/singleArticle/articlePropTypes.js b/app/javascript/modCenter/singleArticle/articlePropTypes.js
new file mode 100644
index 000000000..727a26016
--- /dev/null
+++ b/app/javascript/modCenter/singleArticle/articlePropTypes.js
@@ -0,0 +1,13 @@
+import PropTypes from 'prop-types';
+
+export default PropTypes.shape({
+ id: PropTypes.number,
+ title: PropTypes.string,
+ path: PropTypes.string,
+ cached_tag_list: PropTypes.arrayOf(PropTypes.string),
+ user: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ username: PropTypes.string.isRequired,
+ path: PropTypes.string.isRequired,
+ }),
+});
diff --git a/app/javascript/modCenter/singleArticle/index.jsx b/app/javascript/modCenter/singleArticle/index.jsx
new file mode 100644
index 000000000..42e1a0b75
--- /dev/null
+++ b/app/javascript/modCenter/singleArticle/index.jsx
@@ -0,0 +1,82 @@
+import PropTypes from 'prop-types';
+import { h, Component } from 'preact';
+import { formatDate } from './util';
+
+export default class SingleArticle extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ articleOpened: false,
+ };
+ }
+
+ toggleArticle = (e) => {
+ e.preventDefault();
+
+ const { id, path } = this.props;
+ const { articleOpened } = this.state;
+ if (articleOpened) {
+ this.setState({ articleOpened: false });
+ document.getElementById(`article-iframe-${id}`).innerHTML = '';
+ } else {
+ this.setState({ articleOpened: true });
+ document.getElementById(
+ `article-iframe-${id}`,
+ ).innerHTML = ``;
+ }
+ };
+
+ render() {
+ const { articleOpened } = this.state;
+ const { id, title, publishedAt, cachedTagList, user, key } = this.props;
+ const tags = cachedTagList.split(', ').map((tag) => {
+ return (
+
+ #
+ {tag}
+
+ );
+ });
+
+ const newAuthorNotification = user.articles_count <= 3 ? '👋 ' : '';
+
+ return (
+
+
+
+ {tags}
+
+
+ {newAuthorNotification}
+ {user.name}
+
+
+ {formatDate(publishedAt)}
+
+
+
+ );
+ }
+}
+
+SingleArticle.propTypes = {
+ id: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ path: PropTypes.string.isRequired,
+ publishedAt: PropTypes.string.isRequired,
+ cachedTagList: PropTypes.isRequired,
+ user: PropTypes.isRequired,
+ key: PropTypes.number.isRequired,
+};
diff --git a/app/javascript/modCenter/singleArticle/singleArticle.test.jsx b/app/javascript/modCenter/singleArticle/singleArticle.test.jsx
new file mode 100644
index 000000000..40d2f93b9
--- /dev/null
+++ b/app/javascript/modCenter/singleArticle/singleArticle.test.jsx
@@ -0,0 +1,103 @@
+import { h } from 'preact';
+import { axe } from 'jest-axe';
+import { render, getNodeText, fireEvent } from '@testing-library/preact';
+
+import SingleArticle from './index';
+
+const testArticle = {
+ id: 1,
+ title: 'An article title',
+ path: 'an-article-title-di3',
+ publishedAt: '2019-06-22T16:11:10.590Z',
+ cachedTagList: 'discuss, javascript, beginners',
+ user: {
+ articles_count: 1,
+ name: 'hello',
+ },
+};
+
+describe(' ', () => {
+ const renderSingleArticle = (article = testArticle) =>
+ render(
+ ,
+ );
+
+ it('should have no a11y violations', async () => {
+ const { container } = renderSingleArticle();
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ it('renders the article title', () => {
+ const { getByText } = renderSingleArticle();
+ getByText(testArticle.title);
+ });
+
+ it('renders the tags', () => {
+ const { getByText } = renderSingleArticle();
+ getByText('discuss');
+ getByText('javascript');
+ getByText('beginners');
+ });
+ it('renders the author name', () => {
+ const { container } = renderSingleArticle();
+ const text = getNodeText(container.querySelector('.article-author'));
+ expect(text).toContain(testArticle.user.name);
+ });
+ it('renders the hand wave emoji if the author has less than 3 articles ', () => {
+ const { container } = renderSingleArticle();
+ const text = getNodeText(container.querySelector('.article-author'));
+ expect(text).toContain('👋');
+ });
+ it('renders the correct formatted published date', () => {
+ const { getByText } = renderSingleArticle();
+ getByText('Jun 22');
+ });
+ it('renders the correct formatted published date as a time if the date is the same day', () => {
+ const today = new Date();
+ today.setSeconds('00');
+ testArticle.publishedAt = today.toISOString();
+ const { getByText } = renderSingleArticle(testArticle);
+ const readableTime = today.toLocaleTimeString().replace(':00 ', ' '); // looks like 8:05 PM
+ getByText(readableTime);
+ });
+ it('renders the iframes on click', () => {
+ const { container } = renderSingleArticle();
+ container.querySelector('button.moderation-single-article').click();
+ const iframes = container.querySelectorAll('iframe');
+ expect(iframes.length).toEqual(2);
+
+ const [articleIframe, actionPanelIframe] = iframes;
+ expect(articleIframe.src).toContain(testArticle.path);
+ expect(actionPanelIframe.src).toContain(
+ `${testArticle.path}/actions_panel`,
+ );
+ });
+ it('adds the opened class when opening an article', () => {
+ const toggleArticle = jest.fn();
+ const { container } = render(
+ ,
+ );
+ fireEvent.click(
+ container.querySelector('button.moderation-single-article'),
+ );
+ expect(
+ container.querySelector('.article-iframes-container').classList,
+ ).toContain('opened');
+ });
+});
diff --git a/app/javascript/modCenter/singleArticle/util.js b/app/javascript/modCenter/singleArticle/util.js
new file mode 100644
index 000000000..24926872f
--- /dev/null
+++ b/app/javascript/modCenter/singleArticle/util.js
@@ -0,0 +1,20 @@
+const time_options = {
+ hour: 'numeric',
+ minute: 'numeric',
+};
+
+const date_options = {
+ month: 'short',
+ day: 'numeric',
+};
+
+export const formatDate = (timestamp, currentLocale) => {
+ const dateToday = new Date();
+ const articlePublished = new Date(timestamp);
+ const locale = currentLocale || 'default';
+
+ if (dateToday.toDateString() === articlePublished.toDateString()) {
+ return articlePublished.toLocaleString(locale, time_options);
+ }
+ return articlePublished.toLocaleString(locale, date_options);
+};
diff --git a/app/javascript/packs/modCenter.jsx b/app/javascript/packs/modCenter.jsx
new file mode 100644
index 000000000..98bd3fa1f
--- /dev/null
+++ b/app/javascript/packs/modCenter.jsx
@@ -0,0 +1,38 @@
+import { h, render } from 'preact';
+import { ModerationArticles } from '../modCenter/moderationArticles';
+import { addSnackbarItem, Snackbar } from '../Snackbar';
+
+let elementLoaded = false;
+
+function loadElement() {
+ const root = document.getElementById('mod-index-list');
+ const isMobileDevice = typeof window.orientation !== 'undefined';
+ const snackZone = document.getElementById('snack-zone');
+
+ if (root) {
+ render( , root);
+ }
+
+ if (snackZone) {
+ render( , snackZone);
+ }
+
+ if (isMobileDevice) {
+ addSnackbarItem({
+ message: 'The Mod Center is best viewed on desktop.',
+ addCloseButton: true,
+ });
+ }
+}
+
+window.InstantClick.on('change', () => {
+ if (!elementLoaded) {
+ loadElement();
+ elementLoaded = true;
+ }
+});
+
+if (!elementLoaded) {
+ loadElement();
+ elementLoaded = true;
+}
diff --git a/app/views/moderations/_mod_sidebar_left.html.erb b/app/views/moderations/_mod_sidebar_left.html.erb
new file mode 100644
index 000000000..95343e3c2
--- /dev/null
+++ b/app/views/moderations/_mod_sidebar_left.html.erb
@@ -0,0 +1,51 @@
+
diff --git a/app/views/moderations/_mod_sidebar_right.html.erb b/app/views/moderations/_mod_sidebar_right.html.erb
new file mode 100644
index 000000000..d0e7df7fc
--- /dev/null
+++ b/app/views/moderations/_mod_sidebar_right.html.erb
@@ -0,0 +1,10 @@
+
diff --git a/app/views/moderations/index.html.erb b/app/views/moderations/index.html.erb
index 6aa722579..cefe61754 100644
--- a/app/views/moderations/index.html.erb
+++ b/app/views/moderations/index.html.erb
@@ -1,63 +1,26 @@
-<% title "Moderate" %>
+<% title "Mod Center" %>
+
+<%= javascript_packs_with_chunks_tag "modCenter", defer: true %>
+
<% if current_user&.trusted %>
- <% if @tag %>
-
- <% end %>
- <% cache("mod-data-header-#{params[:tag]}", expires_in: 6.hours) do %>
-
-
<%= "#" + @tag.name.titleize if @tag %> Moderators
-
We build the <%= community_qualified_name %>
+
+ <%= render "moderations/mod_sidebar_left" %>
+
+
+
+
+
- <% end %>
-
-
- <% @articles.each_with_index do |article, i| %>
-
-
<%= article.title %>
-
- <% article.cached_tag_list_array.each do |tag| %>
- <% tag_hash = tag_colors(tag) %>
-
#<%= tag %>
- <% end %>
-
-
-
Quick Mod
-
<%= article.cached_user.name %>
- ・ <%= article.readable_publish_date %>
- <% if article.nth_published_by_author > 0 && article.nth_published_by_author < 4 %>
- ・ 👋
<%= article.nth_published_by_author.ordinalize %> post by this user
- <% end %>
-
-
-
- <% end %>
-
-
<% else %>
@@ -66,24 +29,8 @@
We periodically award some <%= community_name %> members with heightened privileges to help moderate the community.
Email <%= email_link %> if you'd like to be considered right away.
<% unless user_signed_in? %>
-
P.S. You are not currently signed in.
+
P.S. You are not currently signed in.
<% end %>
<% end %>
-
-
diff --git a/spec/requests/moderations_spec.rb b/spec/requests/moderations_spec.rb
index 2c00f861e..a0133ad35 100644
--- a/spec/requests/moderations_spec.rb
+++ b/spec/requests/moderations_spec.rb
@@ -59,15 +59,16 @@ RSpec.describe "Moderations", type: :request do
end
it "grants access to /mod index with articles" do
- create(:article, published: true)
+ article = create(:article, published: true)
get "/mod"
- expect(response.body).to include("We build the")
+ expect(response.body).to include(article.title)
end
it "grants access to /mod/:tag index with articles" do
create(:article, published: true)
get "/mod/#{article.tags.first}"
- expect(response.body).to include("#" + article.tags.first.name.titleize)
+ expect(response.body).to include("#" + article.tags.first.name)
+ expect(response.body).to include(article.title)
end
it "returns not found for inappropriate tags" do