diff --git a/.eslintignore b/.eslintignore index f1626ea2..33e02327 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,3 +3,4 @@ node_modules/* .next coverage !.storybook +public/* diff --git a/components/ArticlePageLayout.js b/components/ArticlePageLayout.js index 7307f272..bd04d658 100644 --- a/components/ArticlePageLayout.js +++ b/components/ArticlePageLayout.js @@ -1,6 +1,7 @@ import gql from 'graphql-tag'; -import { t, jt } from 'ttag'; +import { t, jt, ngettext, msgid } from 'ttag'; import { useRouter } from 'next/router'; +import Link from 'next/link'; import { useQuery } from '@apollo/react-hooks'; import Box from '@material-ui/core/Box'; @@ -11,8 +12,14 @@ import { makeStyles } from '@material-ui/core/styles'; import { ellipsis } from 'lib/text'; import useCurrentUser from 'lib/useCurrentUser'; import * as FILTERS from 'constants/articleFilters'; -import ArticleItem from 'components/ListPageDisplays/ArticleItem'; +import ListPageCards from 'components/ListPageDisplays/ListPageCards'; +import ArticleCard from 'components/ListPageDisplays/ArticleCard'; +import ListPageCard from 'components/ListPageDisplays/ListPageCard'; +import ReplyItem from 'components/ListPageDisplays/ReplyItem'; +import Infos from 'components/Infos'; +import TimeInfo from 'components/Infos/TimeInfo'; import FeedDisplay from 'components/Subscribe/FeedDisplay'; +import ExpandableText from 'components/ExpandableText'; import Filters from 'components/ListPageControls/Filters'; import ArticleStatusFilter from 'components/ListPageControls/ArticleStatusFilter'; import CategoryFilter from 'components/ListPageControls/CategoryFilter'; @@ -29,16 +36,29 @@ const LIST_ARTICLES = gql` $orderBy: [ListArticleOrderBy] $after: String ) { - ListArticles(filter: $filter, orderBy: $orderBy, after: $after, first: 10) { + ListArticles(filter: $filter, orderBy: $orderBy, after: $after, first: 25) { edges { node { - ...ArticleItem + id + replyRequestCount + createdAt + text + articleReplies(status: NORMAL) { + reply { + id + ...ReplyItem + } + ...ReplyItemArticleReplyData + } + ...ArticleCard } cursor } } } - ${ArticleItem.fragments.ArticleItem} + ${ArticleCard.fragments.ArticleCard} + ${ReplyItem.fragments.ReplyItem} + ${ReplyItem.fragments.ReplyItemArticleReplyData} `; const LIST_STAT = gql` @@ -56,13 +76,47 @@ const LIST_STAT = gql` } `; -const useStyles = makeStyles(() => ({ +const useStyles = makeStyles(theme => ({ filters: { margin: '12px 0', }, articleList: { padding: 0, }, + highlight: { + color: theme.palette.primary[500], + }, + noStyleLink: { + // Canceling link styles + color: 'inherit', + textDecoration: 'none', + }, + bustHoaxDivider: { + fontSize: theme.typography.htmlFontSize, + position: 'relative', + display: 'flex', + justifyContent: 'center', + padding: '12px 0', + '&:before': { + position: 'absolute', + top: '50%', + display: 'block', + height: '1px', + width: '100%', + backgroundColor: theme.palette.secondary[100], + content: '""', + }, + '& a': { + position: 'relative', + flex: '1 1 shrink', + borderRadius: 30, + padding: '10px 26px', + textAlign: 'center', + backgroundColor: theme.palette.primary.main, + color: theme.palette.common.white, + zIndex: 2, + }, + }, })); /** @@ -162,7 +216,6 @@ export function getQueryVars(query) { function ArticlePageLayout({ title, - articleDisplayConfig = {}, defaultOrder = 'lastRequestedAt', defaultFilters = [], timeRangeKey = 'createdAt', @@ -171,6 +224,12 @@ function ArticlePageLayout({ consider: true, category: true, }, + + // What "page" the is used. + // FIXME: this is a temporary variable bridging the current with + // future layout with no at all. + // + page, }) { const classes = useStyles(); const { query } = useRouter(); @@ -258,16 +317,57 @@ function ArticlePageLayout({ listArticlesError.toString() ) : ( <> -
    - {articleEdges.map(({ node }) => ( - - ))} -
+ + {/** + * FIXME: the "page" logic will be removed when ArticlePageLayout is splitted into + * each separate page component. + */} + {articleEdges.map(({ node: article }) => + page === 'replies' ? ( + + + <> + {ngettext( + msgid`${article.replyRequestCount} occurrence`, + `${article.replyRequestCount} occurrences`, + article.replyRequestCount + )} + + + {timeAgo => t`First reported ${timeAgo} ago`} + + + {article.text} + + + + {article.articleReplies.map(({ reply, ...articleReply }) => ( + + ))} + + ) : ( + // This will be copied to pages/articles, pages/search and pages/hoax-for-you + // when we remove ArticlePageLayout. + // + + ) + )} + ( ); + +export const WithReplyCountInfo = () => ( + + + + +); diff --git a/components/Infos/ReplyCountInfo.js b/components/Infos/ReplyCountInfo.js new file mode 100644 index 00000000..6d88a69b --- /dev/null +++ b/components/Infos/ReplyCountInfo.js @@ -0,0 +1,79 @@ +import gql from 'graphql-tag'; +import { ngettext, msgid } from 'ttag'; +import { makeStyles } from '@material-ui/core/styles'; +import { TYPE_ICON } from 'constants/replyType'; +import Tooltip from 'components/Tooltip'; + +const useStyles = makeStyles(() => ({ + opinions: { + display: 'flex', + }, + opinion: { + display: 'flex', + alignItems: 'center', + '&:not(:first-child)': { + paddingLeft: 15, + }, + '& > span:nth-child(2)': { + paddingLeft: 4, + }, + }, + optionIcon: { + fontSize: 14, + }, +})); + +function ReplyCountInfo({ normalArticleReplies }) { + const classes = useStyles(); + + const replyCount = normalArticleReplies.length; + const opinions = normalArticleReplies.reduce((result, { replyType }) => { + if (result[replyType]) { + result[replyType] += 1; + } else { + result[replyType] = 1; + } + return result; + }, {}); + + return ( + + {Object.entries(opinions).map(([k, v]) => { + const IconComponent = TYPE_ICON[k]; + return ( + + + {v} + + ); + })} + + ) + } + arrow + > + + {ngettext( + msgid`${replyCount} response`, + `${replyCount} responses`, + replyCount + )} + + + ); +} + +ReplyCountInfo.fragments = { + ReplyCountInfo: gql` + fragment NormalArticleReplyForReplyCountInfo on ArticleReply { + replyType + } + `, +}; + +export default ReplyCountInfo; diff --git a/components/Infos/__snapshots__/Infos.stories.storyshot b/components/Infos/__snapshots__/Infos.stories.storyshot index e7c634d1..ae77d2b3 100644 --- a/components/Infos/__snapshots__/Infos.stories.storyshot +++ b/components/Infos/__snapshots__/Infos.stories.storyshot @@ -34,6 +34,123 @@ exports[`Storyshots Infos With Multiple Children 1`] = ` `; +exports[`Storyshots Infos With Reply Count Info 1`] = ` + +
+ + + + 0 responses + + + + | + + + + + + 2 + + + + + + 1 + + + + + + 1 + + + + + + 1 + + +
+ } + > + + 5 responses + + + + +
+`; + exports[`Storyshots Infos With Time Info 1`] = `
({ + root: { + // Canceling link styles + color: 'inherit', + textDecoration: 'none', + + // Adding visited styles + '&:visited > *': { + backgroundColor: '#fafafa', + '--background': '#fafafa', // ExpandableText + }, + }, + flex: { + display: 'flex', + alignItems: 'flex-start', + marginTop: 10, + [theme.breakpoints.up('md')]: { + marginTop: 14, + }, + }, + infoBox: { + backgroundColor: theme.palette.secondary[50], + borderRadius: 8, + display: 'flex', + padding: '6px 0', + marginRight: 12, + + [theme.breakpoints.up('md')]: { + marginRight: 24, + }, + + '& > div': { + textAlign: 'center', + width: 50, + [theme.breakpoints.up('md')]: { + width: 65, + padding: '4px 0', + }, + '&:first-child': { + borderRight: `1px solid ${theme.palette.secondary[500]}`, + }, + }, + + '& h2': { + margin: 0, + fontSize: 14, + fontWeight: 'normal', + lineHeight: 1, + [theme.breakpoints.up('md')]: { + fontSize: 24, + }, + }, + + '& span': { fontSize: 12 }, + }, + content: { + // fix very very long string layout + lineBreak: 'anywhere', + minWidth: 0, + flex: 1, + fontSize: 12, + color: theme.palette.secondary[500], + [theme.breakpoints.up('md')]: { + fontSize: 14, + }, + }, + highlight: { + color: theme.palette.primary[500], + }, +})); + +/** + * Card for an Article. + * + * @param {Article} props.article + * @param {string?} props.query - the currently searched query string to highlight + */ +function ArticleCard({ article, query = '' }) { + const { id, text, replyCount, replyRequestCount, createdAt } = article; + const classes = useStyles(); + + return ( + + + + + + {timeAgo => t`First reported ${timeAgo} ago`} + + +
+
+
+

{+replyCount}

+ {c('Info box').t`replies`} +
+
+

{+replyRequestCount}

+ {c('Info box').t`reports`} +
+
+ + {highlight(text, { + query, + highlightClassName: classes.highlight, + })} + +
+
+
+ + ); +} + +ArticleCard.fragments = { + ArticleCard: gql` + fragment ArticleCard on Article { + id + text + replyCount + replyRequestCount + createdAt + } + `, +}; + +export default ArticleCard; diff --git a/components/ListPageDisplays/ArticleItem.js b/components/ListPageDisplays/ArticleItem.js deleted file mode 100644 index 65d7e8f5..00000000 --- a/components/ListPageDisplays/ArticleItem.js +++ /dev/null @@ -1,213 +0,0 @@ -import gql from 'graphql-tag'; -import Link from 'next/link'; -import { c, t } from 'ttag'; -import { makeStyles } from '@material-ui/core/styles'; -import { highlight } from 'lib/text'; -import ArticleInfo from 'components/ArticleInfo'; -import ExpandableText from 'components/ExpandableText'; -import ReplyItem from './ReplyItem'; -import cx from 'clsx'; - -const useStyles = makeStyles(theme => ({ - root: { - '--list-item-padding': '16px', - display: 'block', - position: 'relative', - padding: 'var(--list-item-padding)', - marginBottom: 12, - borderRadius: 8, - textDecoration: 'none', - color: ({ read }) => (read ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.88)'), - background: ({ isArticle = true, read }) => { - if (!isArticle) { - return '#feff3b45'; - } - if (read) { - return '#f1f1f1'; - } - return theme.palette.common.white; - }, - '&:first-child': { - border: 0, - }, - '& a': { - textDecoration: 'none', - color: 'inherit', - }, - [theme.breakpoints.up('md')]: { - '--list-item-padding': '36px', - }, - }, - flex: { - display: 'flex', - }, - infoBox: { - backgroundColor: theme.palette.secondary[50], - borderRadius: 8, - display: 'flex', - padding: 6, - marginRight: 12, - maxHeight: 56, - marginTop: 10, - [theme.breakpoints.up('md')]: { - padding: '6px 10px', - marginRight: 20, - maxHeight: 64, - marginTop: 14, - }, - '& > div': { - textAlign: 'center', - width: 55, - '&:first-child': { - borderRight: `1px solid ${theme.palette.secondary[500]}`, - }, - }, - '& h2': { - margin: 0, - fontSize: 14, - [theme.breakpoints.up('md')]: { - fontSize: 24, - }, - }, - '& span': { - fontSize: 12, - [theme.breakpoints.up('md')]: { - fontSize: 14, - }, - }, - }, - link: { - fontSize: theme.typography.htmlFontSize, - position: 'relative', - display: 'flex', - justifyContent: 'center', - padding: '12px 0', - '&:before': { - position: 'absolute', - top: '50%', - display: 'block', - height: '1px', - width: '100%', - backgroundColor: theme.palette.secondary[100], - content: '""', - }, - '& a': { - position: 'relative', - flex: '1 1 shrink', - borderRadius: 30, - padding: '10px 26px', - textAlign: 'center', - backgroundColor: theme.palette.primary.main, - color: theme.palette.common.white, - zIndex: 2, - }, - }, - content: { - // fix very very long string layout - lineBreak: 'anywhere', - minWidth: 1, - margin: '12px 0', - flex: 1, - fontSize: 12, - [theme.breakpoints.up('md')]: { - fontSize: 14, - }, - }, - highlight: { - color: theme.palette.primary[500], - }, -})); - -export default function ArticleItem({ - article, - read = false, // from localEditorHelperList, it only provide after did mount - notArticleReplied = false, // same as top - isLink = true, - showLastReply = false, - showReplyCount = true, - query = '', - className, - // handleLocalEditorHelperList, - // isLogin, -}) { - const { text, replyCount, replyRequestCount } = article; - const classes = useStyles({ - read, - isArticle: !notArticleReplied, - }); - - const content = ( - <> - -
- {showReplyCount && ( -
-
-

{+replyCount}

- {c('Info box').t`replies`} -
-
-

{+replyRequestCount}

- {c('Info box').t`requests`} -
-
- )} - - {highlight(text, { - query, - highlightClassName: classes.highlight, - })} - -
- - ); - - return ( -
  • - {isLink ? ( - - {content} - - ) : ( - content - )} - {isLink || ( - - )} - - {showLastReply && - article.articleReplies.map(articleReply => ( - - ))} -
  • - ); -} - -ArticleItem.displayName = 'ArticleItem'; - -ArticleItem.fragments = { - ArticleItem: gql` - fragment ArticleItem on Article { - id - text - articleReplies(status: NORMAL) { - ...ReplyItemArticleReplyData - reply { - ...ReplyItem - } - } - ...ArticleInfo - } - ${ArticleInfo.fragments.articleInfo} - ${ReplyItem.fragments.ReplyItem} - ${ReplyItem.fragments.ReplyItemArticleReplyData} - `, -}; diff --git a/components/ListPageDisplays/ListPageCard.js b/components/ListPageDisplays/ListPageCard.js new file mode 100644 index 00000000..5c9ff2a0 --- /dev/null +++ b/components/ListPageDisplays/ListPageCard.js @@ -0,0 +1,31 @@ +import cx from 'clsx'; +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles(theme => ({ + root: { + borderRadius: 8, + background: theme.palette.common.white, + overflowWrap: 'anywhere', // Make sure long URLs don't stretch card extend its container + '& a': { + textDecoration: 'none', + color: 'inherit', + }, + padding: 12, + [theme.breakpoints.up('md')]: { + padding: '28px 36px', + }, + }, +})); + +/** + * Base card in list page with basic styling. + * + * @param {string?} className + */ +function ListPageCard({ className = '', ...otherProps }) { + const classes = useStyles(); + + return
    ; +} + +export default ListPageCard; diff --git a/components/ListPageDisplays/ListPageCards.js b/components/ListPageDisplays/ListPageCards.js new file mode 100644 index 00000000..1045dda4 --- /dev/null +++ b/components/ListPageDisplays/ListPageCards.js @@ -0,0 +1,22 @@ +import cx from 'clsx'; +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles(() => ({ + root: { + display: 'grid', + gridRowGap: 12, + }, +})); + +/** + * Container for all cards in list page. + * + * @param {string?} className + */ +function ListPageCards({ className, ...otherProps }) { + const classes = useStyles(); + + return
    ; +} + +export default ListPageCards; diff --git a/components/ListPageDisplays/ListPageCards.stories.js b/components/ListPageDisplays/ListPageCards.stories.js new file mode 100644 index 00000000..897cf9c3 --- /dev/null +++ b/components/ListPageDisplays/ListPageCards.stories.js @@ -0,0 +1,50 @@ +import React from 'react'; +import { withKnobs, text } from '@storybook/addon-knobs'; +import ListPageCards from './ListPageCards'; +import ListPageCard from './ListPageCard'; +import ArticleCard from './ArticleCard'; + +export default { + title: 'ListPageDisplays/ListPageCards', + component: 'ListPageCard', + decorators: [withKnobs], +}; + +export const CardsAndCard = () => ( + + {Array.from(Array(3)).map((_, idx) => ( + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris eu ex + augue. Etiam posuere sagittis iaculis. Vestibulum sollicitudin nec felis + a mollis. Phasellus ut est velit. Proin fermentum arcu ornare quam + vulputate, vel eleifend velit ultrices. Fusce tincidunt vel urna at + luctus. + + ))} + +); + +export const ArticleCards = () => ( + + + + +); diff --git a/components/ListPageDisplays/ReplySearchItem.js b/components/ListPageDisplays/ReplySearchItem.js index d257d8d8..89af661c 100644 --- a/components/ListPageDisplays/ReplySearchItem.js +++ b/components/ListPageDisplays/ReplySearchItem.js @@ -1,6 +1,7 @@ import { useState } from 'react'; import gql from 'graphql-tag'; import { t, msgid, ngettext } from 'ttag'; +import Link from 'next/link'; import { makeStyles } from '@material-ui/core/styles'; import { Box, @@ -9,10 +10,9 @@ import { DialogTitle, DialogContent, } from '@material-ui/core'; -import ArticleInfo from 'components/ArticleInfo'; -import PlainList from 'components/PlainList'; import ExpandableText from 'components/ExpandableText'; -import ArticleItem from './ArticleItem'; +import Infos from 'components/Infos'; +import TimeInfo from 'components/Infos/TimeInfo'; import ReplyItem from './ReplyItem'; import { nl2br } from 'lib/text'; import VisibilityIcon from '@material-ui/icons/Visibility'; @@ -35,6 +35,9 @@ const useStyles = makeStyles(theme => ({ color: 'inherit', }, }, + info: { + marginBottom: 12, + }, content: { // fix very very long string layout lineBreak: 'anywhere', @@ -76,17 +79,40 @@ const useStyles = makeStyles(theme => ({ alignItems: 'center', }, }, - article: { - padding: 0, - borderRadius: 0, - '&:not(:last-child)': { - paddingBottom: 12, - marginBottom: theme.spacing(3), - borderBottom: `1px solid ${theme.palette.secondary[200]}`, + otherArticleItem: { + display: 'block', + // Canceling link styles + color: 'inherit', + textDecoration: 'none', + paddingBottom: 24, + borderBottom: `1px solid ${theme.palette.secondary[200]}`, + marginBottom: 24, + + '&:last-child': { + borderBottom: 0, + marginBottom: 0, }, }, })); +function RepliedArticleInfo({ article }) { + const classes = useStyles(); + return ( + + <> + {ngettext( + msgid`${article.replyRequestCount} occurrence`, + `${article.replyRequestCount} occurrences`, + article.replyRequestCount + )} + + + {timeAgo => t`First reported ${timeAgo} ago`} + + + ); +} + export default function ReplySearchItem({ articleReplies = [], query = '', @@ -111,7 +137,7 @@ export default function ReplySearchItem({ return (
  • - +
    {nl2br(articleReply.article.text)} @@ -146,18 +172,22 @@ export default function ReplySearchItem({ {t`This reply is used in following messages`} - - {articleReplies - .filter(ar => ar !== articleReply) - .map(({ article }) => ( - - ))} - + {articleReplies + .filter(ar => ar !== articleReply) + .map(({ article }) => ( + + + + + {article.text} + + + + ))} @@ -176,15 +206,13 @@ ReplySearchItem.fragments = { article { id text - ...ArticleInfo - ...ArticleItem + replyRequestCount + createdAt } ...ReplyItemArticleReplyData } ...ReplyItem } - ${ArticleInfo.fragments.articleInfo} - ${ArticleItem.fragments.ArticleItem} ${ReplyItem.fragments.ReplyItem} ${ReplyItem.fragments.ReplyItemArticleReplyData} `, diff --git a/components/ListPageDisplays/__snapshots__/ListPageCards.stories.storyshot b/components/ListPageDisplays/__snapshots__/ListPageCards.stories.storyshot new file mode 100644 index 00000000..8ec69a1e --- /dev/null +++ b/components/ListPageDisplays/__snapshots__/ListPageCards.stories.storyshot @@ -0,0 +1,254 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots ListPageDisplays/ListPageCards Article Cards 1`] = ` + + + +`; + +exports[`Storyshots ListPageDisplays/ListPageCards Cards And Card 1`] = ` + +
    + +
    + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris eu ex augue. Etiam posuere sagittis iaculis. Vestibulum sollicitudin nec felis a mollis. Phasellus ut est velit. Proin fermentum arcu ornare quam vulputate, vel eleifend velit ultrices. Fusce tincidunt vel urna at luctus. +
    +
    + +
    + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris eu ex augue. Etiam posuere sagittis iaculis. Vestibulum sollicitudin nec felis a mollis. Phasellus ut est velit. Proin fermentum arcu ornare quam vulputate, vel eleifend velit ultrices. Fusce tincidunt vel urna at luctus. +
    +
    + +
    + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris eu ex augue. Etiam posuere sagittis iaculis. Vestibulum sollicitudin nec felis a mollis. Phasellus ut est velit. Proin fermentum arcu ornare quam vulputate, vel eleifend velit ultrices. Fusce tincidunt vel urna at luctus. +
    +
    +
    +
    +`; diff --git a/pages/articles.js b/pages/articles.js index ac9b18b3..8640bfb1 100644 --- a/pages/articles.js +++ b/pages/articles.js @@ -30,7 +30,7 @@ function ArticleListPage() { href={`${PUBLIC_URL}/api/articles/atom1?${queryString}`} /> - + ); } diff --git a/pages/hoax-for-you.js b/pages/hoax-for-you.js index 8a423a87..3b4ec7b1 100644 --- a/pages/hoax-for-you.js +++ b/pages/hoax-for-you.js @@ -42,6 +42,7 @@ function HoaxForYouPage() { category: true, }} defaultFilters={[NO_USEFUL_REPLY_YET, ASKED_MANY_TIMES]} + page="hoax-for-you" /> ); diff --git a/pages/replies.js b/pages/replies.js index 198a88e5..ff7d7664 100644 --- a/pages/replies.js +++ b/pages/replies.js @@ -32,13 +32,9 @@ function ReplyListPage() { ); diff --git a/pages/search.js b/pages/search.js index 7d4951c5..51e5fa0c 100644 --- a/pages/search.js +++ b/pages/search.js @@ -171,7 +171,7 @@ function SearchPage() {
    {query.type === 'messages' && ( - + )} {query.type === 'replies' && }