From b3b1c48d92948a839fad681302fd8b614162d726 Mon Sep 17 00:00:00 2001 From: bniwredyc Date: Tue, 28 Feb 2023 18:13:14 +0100 Subject: [PATCH] likes dislikes --- src/components/Article/CommentsTree.tsx | 150 ++++++++---------- src/components/Article/FullArticle.tsx | 91 ++++++++--- .../Article/RatingControl.module.scss | 7 + src/components/Article/RatingControl.tsx | 11 +- src/components/Feed/Card.tsx | 57 ++++++- src/components/Views/Feed.tsx | 9 +- src/context/reactions.tsx | 26 ++- src/pages/about/help.page.tsx | 7 +- src/stores/zine/articles.ts | 22 ++- src/utils/apiClient.ts | 2 +- src/utils/checkReaction.ts | 11 ++ 11 files changed, 269 insertions(+), 124 deletions(-) create mode 100644 src/utils/checkReaction.ts diff --git a/src/components/Article/CommentsTree.tsx b/src/components/Article/CommentsTree.tsx index 392b12cf..e9959dcd 100644 --- a/src/components/Article/CommentsTree.tsx +++ b/src/components/Article/CommentsTree.tsx @@ -22,16 +22,14 @@ type Props = { } export const CommentsTree = (props: Props) => { - const [isCommentsLoading, setIsCommentsLoading] = createSignal(false) const [commentsOrder, setCommentsOrder] = createSignal('createdAt') const { reactionEntities, - actions: { loadReactionsBy, createReaction } + actions: { createReaction } } = useReactions() const { t } = useLocalize() - // TODO: server side? const [newReactionsCount, setNewReactionsCount] = createSignal(0) const [newReactions, setNewReactions] = createSignal([]) @@ -79,7 +77,9 @@ export const CommentsTree = (props: Props) => { setCookie() } else if (Date.now() > dateFromCookie) { const newComments = comments().filter((c) => { - if (c.replyTo) return + if (c.replyTo) { + return + } const commentDate = new Date(c.createdAt).valueOf() return commentDate > dateFromCookie }) @@ -92,15 +92,7 @@ export const CommentsTree = (props: Props) => { const { session } = useSession() onMount(async () => { - try { - setIsCommentsLoading(true) - await loadReactionsBy({ - by: { shout: props.shoutSlug } - }) - updateNewReactionsCount() - } finally { - setIsCommentsLoading(false) - } + updateNewReactionsCount() }) const [submitted, setSubmitted] = createSignal(false) @@ -118,80 +110,78 @@ export const CommentsTree = (props: Props) => { } return ( -
- }> -
-

- {t('Comments')} {comments().length.toString() || ''} - 0}> -  +{newReactionsCount()} - -

+ <> +
+

+ {t('Comments')} {comments().length.toString() || ''} + 0}> +  +{newReactionsCount()} + +

-
    - 0}> -
  • -
  • -
    -
  • +
      + 0}> +
    • -
    • -
    • -
    -
-
    - !r.replyTo)}> - {(reaction) => ( - a.slug === session()?.user.slug))} - comment={reaction} - /> - )} - + +
  • +
  • +
  • +
- - {t('To write a comment, you must')}  - - {t('sign up')} - -  {t('or')}  - - {t('sign in')} - -
- } - > - handleSubmitComment(value)} - /> - -
-
+ +
    + !r.replyTo)}> + {(reaction) => ( + a.slug === session()?.user.slug))} + comment={reaction} + /> + )} + +
+ + {t('To write a comment, you must')}  + + {t('sign up')} + +  {t('or')}  + + {t('sign in')} + + + } + > + handleSubmitComment(value)} + /> + + ) } diff --git a/src/components/Article/FullArticle.tsx b/src/components/Article/FullArticle.tsx index 0eab341c..d87723a2 100644 --- a/src/components/Article/FullArticle.tsx +++ b/src/components/Article/FullArticle.tsx @@ -2,8 +2,8 @@ import { capitalize, formatDate } from '../../utils' import './Full.scss' import { Icon } from '../_shared/Icon' import { AuthorCard } from '../Author/Card' -import { createMemo, For, Match, onMount, Show, Switch } from 'solid-js' -import type { Author, Shout } from '../../graphql/types.gen' +import { createEffect, createMemo, createSignal, For, Match, onMount, Show, Switch } from 'solid-js' +import type { Author, Reaction, Shout } from '../../graphql/types.gen' import { ReactionKind } from '../../graphql/types.gen' import MD from './MD' @@ -23,6 +23,7 @@ import { useReactions } from '../../context/reactions' import { loadShout } from '../../stores/zine/articles' import { Title } from '@solidjs/meta' import { useLocalize } from '../../context/localize' +import { checkReaction } from '../../utils/checkReaction' interface ArticleProps { article: Shout @@ -59,7 +60,8 @@ const MediaView = (props: { media: MediaItem; kind: Shout['layout'] }) => { export const FullArticle = (props: ArticleProps) => { const { t } = useLocalize() - const { session } = useSession() + const { userSlug, session } = useSession() + const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false) const formattedDate = createMemo(() => formatDate(new Date(props.article.createdAt))) const mainTopic = createMemo( @@ -81,6 +83,14 @@ export const FullArticle = (props: ArticleProps) => { } }) + onMount(async () => { + await loadReactionsBy({ + by: { shout: props.article.slug } + }) + + setIsReactionsLoaded(true) + }) + const canEdit = () => props.article.authors?.some((a) => a.slug === session()?.user?.slug) const bookmark = (ev) => { @@ -96,27 +106,52 @@ export const FullArticle = (props: ArticleProps) => { }) const { - actions: { createReaction } + reactionEntities, + actions: { loadReactionsBy, createReaction, deleteReaction } } = useReactions() - const handleUpvote = async () => { - await createReaction({ - kind: ReactionKind.Like, - shout: props.article.id + const updateReactions = () => { + loadReactionsBy({ + by: { shout: props.article.slug } }) - - await loadShout(props.article.slug) } - const handleDownvote = async () => { - await createReaction({ - kind: ReactionKind.Dislike, - shout: props.article.id - }) + const isUpvoted = createMemo(() => + checkReaction(Object.values(reactionEntities), ReactionKind.Like, userSlug(), props.article.id) + ) - await loadShout(props.article.slug) + const isDownvoted = createMemo(() => + checkReaction(Object.values(reactionEntities), ReactionKind.Dislike, userSlug(), props.article.id) + ) + + const handleRatingChange = async (isUpvote: boolean) => { + const reactionKind = isUpvote ? ReactionKind.Like : ReactionKind.Dislike + const isReacted = (isUpvote && isUpvoted()) || (!isUpvote && isDownvoted()) + + if (isReacted) { + const reactionToDelete = Object.values(reactionEntities).find( + (r) => + r.kind === reactionKind && + r.createdBy.slug === userSlug() && + r.shout.id === props.article.id && + !r.replyTo + ) + await deleteReaction(reactionToDelete.id) + } else { + await createReaction({ + kind: reactionKind, + shout: props.article.id + }) + } + + loadShout(props.article.slug) + updateReactions() } + createEffect(() => { + console.log('reactions', reactionEntities) + }) + return ( <> {props.article.title} @@ -200,8 +235,10 @@ export const FullArticle = (props: ArticleProps) => { handleRatingChange(true)} + onDownvote={() => handleRatingChange(false)} + isUpvoted={isUpvoted()} + isDownvoted={isDownvoted()} /> @@ -265,22 +302,24 @@ export const FullArticle = (props: ArticleProps) => {
- 1}> + 1}>

{t('Authors')}

- - {(a: Author) => ( + + {(a) => (
)}
- + + + diff --git a/src/components/Article/RatingControl.module.scss b/src/components/Article/RatingControl.module.scss index 1d871050..7e2e72cf 100644 --- a/src/components/Article/RatingControl.module.scss +++ b/src/components/Article/RatingControl.module.scss @@ -1,6 +1,13 @@ .rating { align-items: center; display: flex; + + &.isDownvoted .downvoteButton, + &.isUpvoted .upvoteButton { + background: #000; + border-color: #000; + color: #fff; + } } .ratingValue { diff --git a/src/components/Article/RatingControl.tsx b/src/components/Article/RatingControl.tsx index e10dad7f..bfed81bb 100644 --- a/src/components/Article/RatingControl.tsx +++ b/src/components/Article/RatingControl.tsx @@ -12,12 +12,17 @@ interface RatingControlProps { export const RatingControl = (props: RatingControlProps) => { return ( -
- {props?.rating || ''} -
diff --git a/src/components/Feed/Card.tsx b/src/components/Feed/Card.tsx index a8096349..c2cc1b7b 100644 --- a/src/components/Feed/Card.tsx +++ b/src/components/Feed/Card.tsx @@ -12,6 +12,11 @@ import stylesHeader from '../Nav/Header.module.scss' import { getDescription } from '../../utils/meta' import { FeedArticlePopup } from './FeedArticlePopup' import { useLocalize } from '../../context/localize' +import { ReactionKind } from '../../graphql/types.gen' +import { loadShout } from '../../stores/zine/articles' +import { useReactions } from '../../context/reactions' +import { checkReaction } from '../../utils/checkReaction' +import { useSession } from '../../context/session' interface ArticleCardProps { settings?: { @@ -61,6 +66,13 @@ const getTitleAndSubtitle = (article: Shout): { title: string; subtitle: string export const ArticleCard = (props: ArticleCardProps) => { const { t, lang } = useLocalize() + const { userSlug } = useSession() + + const { + reactionEntities, + actions: { createReaction, deleteReaction, loadReactionsBy } + } = useReactions() + const mainTopic = props.article.topics.find((articleTopic) => articleTopic.slug === props.article.mainTopic) || props.article.topics[0] @@ -73,7 +85,41 @@ export const ArticleCard = (props: ArticleCardProps) => { const { title, subtitle } = getTitleAndSubtitle(props.article) - const { cover, layout, slug, authors, stat, body } = props.article + const { cover, layout, slug, authors, stat, body, id } = props.article + + const updateReactions = () => { + loadReactionsBy({ + by: { shout: slug } + }) + } + + const isUpvoted = createMemo(() => + checkReaction(Object.values(reactionEntities), ReactionKind.Like, userSlug(), id) + ) + + const isDownvoted = createMemo(() => + checkReaction(Object.values(reactionEntities), ReactionKind.Dislike, userSlug(), id) + ) + + const handleRatingChange = async (isUpvote: boolean) => { + const reactionKind = isUpvote ? ReactionKind.Like : ReactionKind.Dislike + const isReacted = (isUpvote && isUpvoted()) || (!isUpvote && isDownvoted()) + + if (isReacted) { + const reactionToDelete = Object.values(reactionEntities).find( + (r) => r.kind === reactionKind && r.createdBy.slug === userSlug() && r.shout.id === id && !r.replyTo + ) + await deleteReaction(reactionToDelete.id) + } else { + await createReaction({ + kind: reactionKind, + shout: id + }) + } + + loadShout(slug) + updateReactions() + } return (
{
- + handleRatingChange(true)} + onDownvote={() => handleRatingChange(false)} + isUpvoted={isUpvoted()} + isDownvoted={isDownvoted()} + />
diff --git a/src/components/Views/Feed.tsx b/src/components/Views/Feed.tsx index b5ae5444..fe7cafbd 100644 --- a/src/components/Views/Feed.tsx +++ b/src/components/Views/Feed.tsx @@ -59,11 +59,18 @@ export const FeedView = () => { }) const loadMore = async () => { - const { hasMore } = await loadShouts({ + const { hasMore, newShouts } = await loadShouts({ filters: { visibility: 'community' }, limit: FEED_PAGE_SIZE, offset: sortedArticles().length }) + + loadReactionsBy({ + by: { + shouts: newShouts.map((s) => s.slug) + } + }) + setIsLoadMoreButtonVisible(hasMore) } diff --git a/src/context/reactions.tsx b/src/context/reactions.tsx index af9d9b6a..4415c063 100644 --- a/src/context/reactions.tsx +++ b/src/context/reactions.tsx @@ -1,6 +1,7 @@ import type { JSX } from 'solid-js' import { createContext, onCleanup, useContext } from 'solid-js' import type { Reaction, ReactionBy, ReactionInput } from '../graphql/types.gen' +import { ReactionKind } from '../graphql/types.gen' import { apiClient } from '../utils/apiClient' import { createStore } from 'solid-js/store' @@ -41,12 +42,33 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => { const createReaction = async (input: ReactionInput): Promise => { const reaction = await apiClient.createReaction(input) - setReactionEntities(reaction.id, reaction) + + const changes = { + [reaction.id]: reaction + } + + if ([ReactionKind.Like, ReactionKind.Dislike].includes(reaction.kind)) { + const oppositeReactionKind = + reaction.kind === ReactionKind.Like ? ReactionKind.Dislike : ReactionKind.Like + + const oppositeReaction = Object.values(reactionEntities).find( + (r) => + r.kind === oppositeReactionKind && + r.createdBy.slug === reaction.createdBy.slug && + r.shout.id === reaction.shout.id && + r.replyTo === reaction.replyTo + ) + + if (oppositeReaction) { + changes[oppositeReaction.id] = undefined + } + } + + setReactionEntities(changes) } const deleteReaction = async (id: number): Promise => { const reaction = await apiClient.destroyReaction(id) - console.debug('[deleteReaction]:', reaction.id) setReactionEntities((oldState) => ({ ...oldState, [reaction.id]: undefined diff --git a/src/pages/about/help.page.tsx b/src/pages/about/help.page.tsx index 50d9ffc7..9c629d25 100644 --- a/src/pages/about/help.page.tsx +++ b/src/pages/about/help.page.tsx @@ -2,16 +2,19 @@ import { createSignal, Show } from 'solid-js' import { PageLayout } from '../../components/_shared/PageLayout' import { Donate } from '../../components/Discours/Donate' import { Icon } from '../../components/_shared/Icon' - -// const title = t('Support us') +import { Meta, Title } from '@solidjs/meta' +import { useLocalize } from '../../context/localize' export const HelpPage = () => { const [indexExpanded, setIndexExpanded] = createSignal(true) + const { t } = useLocalize() + const toggleIndexExpanded = () => setIndexExpanded((oldExpanded) => !oldExpanded) return ( + {t('Support us')} Здесь можно поддержать Дискурс материально. Discours.io, помощь, благотворительность diff --git a/src/stores/zine/articles.ts b/src/stores/zine/articles.ts index 2ff3c81a..161e9c64 100644 --- a/src/stores/zine/articles.ts +++ b/src/stores/zine/articles.ts @@ -126,24 +126,32 @@ const addSortedArticles = (articles: Shout[]) => { export const loadShout = async (slug: string): Promise => { const newArticle = await apiClient.getShout(slug) addArticles([newArticle]) + const newArticleIndex = sortedArticles().findIndex((s) => s.id === newArticle.id) + if (newArticleIndex >= 0) { + const newSortedArticles = [...sortedArticles()] + newSortedArticles[newArticleIndex] = newArticle + setSortedArticles(newSortedArticles) + } } -export const loadShouts = async (options: LoadShoutsOptions): Promise<{ hasMore: boolean }> => { - const newArticles = await apiClient.getShouts({ +export const loadShouts = async ( + options: LoadShoutsOptions +): Promise<{ hasMore: boolean; newShouts: Shout[] }> => { + const newShouts = await apiClient.getShouts({ ...options, limit: options.limit + 1 }) - const hasMore = newArticles.length === options.limit + 1 + const hasMore = newShouts.length === options.limit + 1 if (hasMore) { - newArticles.splice(-1) + newShouts.splice(-1) } - addArticles(newArticles) - addSortedArticles(newArticles) + addArticles(newShouts) + addSortedArticles(newShouts) - return { hasMore } + return { hasMore, newShouts } } export const resetSortedArticles = () => { diff --git a/src/utils/apiClient.ts b/src/utils/apiClient.ts index 42e85917..3c598f83 100644 --- a/src/utils/apiClient.ts +++ b/src/utils/apiClient.ts @@ -278,7 +278,7 @@ export const apiClient = { const resp = await publicGraphQLClient .query(reactionsLoadBy, { by, limit: limit ?? 1000, offset: 0 }) .toPromise() - console.debug(resp) + // console.debug(resp) return resp.data.loadReactionsBy }, diff --git a/src/utils/checkReaction.ts b/src/utils/checkReaction.ts new file mode 100644 index 00000000..19f157ea --- /dev/null +++ b/src/utils/checkReaction.ts @@ -0,0 +1,11 @@ +import type { Reaction, ReactionKind } from '../graphql/types.gen' + +export const checkReaction = ( + reactions: Reaction[], + reactionKind: ReactionKind, + userSlug: string, + shoutId: number +) => + reactions.some( + (r) => r.kind === reactionKind && r.createdBy.slug === userSlug && r.shout.id === shoutId && !r.replyTo + )