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 ( + + ); + } +} + +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" %> +
+
+ + <%= @tag ? "##{@tag.name}" : "All topics" %> + +
+
+

Post

+

Author

+

Date

+
+
+
- <% end %> - -
- <% @articles.each_with_index do |article, i| %> -
- <%= article.title %> - -
- - <%= 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