likes dislikes

This commit is contained in:
bniwredyc 2023-02-28 18:13:14 +01:00
parent dc85e222b6
commit b3b1c48d92
11 changed files with 269 additions and 124 deletions

View File

@ -22,16 +22,14 @@ type Props = {
} }
export const CommentsTree = (props: Props) => { export const CommentsTree = (props: Props) => {
const [isCommentsLoading, setIsCommentsLoading] = createSignal(false)
const [commentsOrder, setCommentsOrder] = createSignal<CommentsOrder>('createdAt') const [commentsOrder, setCommentsOrder] = createSignal<CommentsOrder>('createdAt')
const { const {
reactionEntities, reactionEntities,
actions: { loadReactionsBy, createReaction } actions: { createReaction }
} = useReactions() } = useReactions()
const { t } = useLocalize() const { t } = useLocalize()
// TODO: server side?
const [newReactionsCount, setNewReactionsCount] = createSignal<number>(0) const [newReactionsCount, setNewReactionsCount] = createSignal<number>(0)
const [newReactions, setNewReactions] = createSignal<Reaction[]>([]) const [newReactions, setNewReactions] = createSignal<Reaction[]>([])
@ -79,7 +77,9 @@ export const CommentsTree = (props: Props) => {
setCookie() setCookie()
} else if (Date.now() > dateFromCookie) { } else if (Date.now() > dateFromCookie) {
const newComments = comments().filter((c) => { const newComments = comments().filter((c) => {
if (c.replyTo) return if (c.replyTo) {
return
}
const commentDate = new Date(c.createdAt).valueOf() const commentDate = new Date(c.createdAt).valueOf()
return commentDate > dateFromCookie return commentDate > dateFromCookie
}) })
@ -92,15 +92,7 @@ export const CommentsTree = (props: Props) => {
const { session } = useSession() const { session } = useSession()
onMount(async () => { onMount(async () => {
try {
setIsCommentsLoading(true)
await loadReactionsBy({
by: { shout: props.shoutSlug }
})
updateNewReactionsCount() updateNewReactionsCount()
} finally {
setIsCommentsLoading(false)
}
}) })
const [submitted, setSubmitted] = createSignal<boolean>(false) const [submitted, setSubmitted] = createSignal<boolean>(false)
@ -118,8 +110,7 @@ export const CommentsTree = (props: Props) => {
} }
return ( return (
<div> <>
<Show when={!isCommentsLoading()} fallback={<Loading />}>
<div class={styles.commentsHeaderWrapper}> <div class={styles.commentsHeaderWrapper}>
<h2 id="comments" class={styles.commentsHeader}> <h2 id="comments" class={styles.commentsHeader}>
{t('Comments')} {comments().length.toString() || ''} {t('Comments')} {comments().length.toString() || ''}
@ -191,7 +182,6 @@ export const CommentsTree = (props: Props) => {
onSubmit={(value) => handleSubmitComment(value)} onSubmit={(value) => handleSubmitComment(value)}
/> />
</ShowIfAuthenticated> </ShowIfAuthenticated>
</Show> </>
</div>
) )
} }

View File

@ -2,8 +2,8 @@ import { capitalize, formatDate } from '../../utils'
import './Full.scss' import './Full.scss'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import { AuthorCard } from '../Author/Card' import { AuthorCard } from '../Author/Card'
import { createMemo, For, Match, onMount, Show, Switch } from 'solid-js' import { createEffect, createMemo, createSignal, For, Match, onMount, Show, Switch } from 'solid-js'
import type { Author, Shout } from '../../graphql/types.gen' import type { Author, Reaction, Shout } from '../../graphql/types.gen'
import { ReactionKind } from '../../graphql/types.gen' import { ReactionKind } from '../../graphql/types.gen'
import MD from './MD' import MD from './MD'
@ -23,6 +23,7 @@ import { useReactions } from '../../context/reactions'
import { loadShout } from '../../stores/zine/articles' import { loadShout } from '../../stores/zine/articles'
import { Title } from '@solidjs/meta' import { Title } from '@solidjs/meta'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
import { checkReaction } from '../../utils/checkReaction'
interface ArticleProps { interface ArticleProps {
article: Shout article: Shout
@ -59,7 +60,8 @@ const MediaView = (props: { media: MediaItem; kind: Shout['layout'] }) => {
export const FullArticle = (props: ArticleProps) => { export const FullArticle = (props: ArticleProps) => {
const { t } = useLocalize() 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 formattedDate = createMemo(() => formatDate(new Date(props.article.createdAt)))
const mainTopic = createMemo( 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 canEdit = () => props.article.authors?.some((a) => a.slug === session()?.user?.slug)
const bookmark = (ev) => { const bookmark = (ev) => {
@ -96,27 +106,52 @@ export const FullArticle = (props: ArticleProps) => {
}) })
const { const {
actions: { createReaction } reactionEntities,
actions: { loadReactionsBy, createReaction, deleteReaction }
} = useReactions() } = useReactions()
const handleUpvote = async () => { const updateReactions = () => {
await createReaction({ loadReactionsBy({
kind: ReactionKind.Like, by: { shout: props.article.slug }
shout: props.article.id
}) })
await loadShout(props.article.slug)
} }
const handleDownvote = async () => { const isUpvoted = createMemo(() =>
checkReaction(Object.values(reactionEntities), ReactionKind.Like, userSlug(), props.article.id)
)
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({ await createReaction({
kind: ReactionKind.Dislike, kind: reactionKind,
shout: props.article.id shout: props.article.id
}) })
await loadShout(props.article.slug)
} }
loadShout(props.article.slug)
updateReactions()
}
createEffect(() => {
console.log('reactions', reactionEntities)
})
return ( return (
<> <>
<Title>{props.article.title}</Title> <Title>{props.article.title}</Title>
@ -200,8 +235,10 @@ export const FullArticle = (props: ArticleProps) => {
<RatingControl <RatingControl
rating={props.article.stat?.rating} rating={props.article.stat?.rating}
class={styles.ratingControl} class={styles.ratingControl}
onUpvote={handleUpvote} onUpvote={() => handleRatingChange(true)}
onDownvote={handleDownvote} onDownvote={() => handleRatingChange(false)}
isUpvoted={isUpvoted()}
isDownvoted={isDownvoted()}
/> />
</div> </div>
@ -265,22 +302,24 @@ export const FullArticle = (props: ArticleProps) => {
</div> </div>
<div class={styles.shoutAuthorsList}> <div class={styles.shoutAuthorsList}>
<Show when={props.article?.authors?.length > 1}> <Show when={props.article.authors.length > 1}>
<h4>{t('Authors')}</h4> <h4>{t('Authors')}</h4>
</Show> </Show>
<For each={props.article?.authors}> <For each={props.article.authors}>
{(a: Author) => ( {(a) => (
<div class="col-xl-6"> <div class="col-xl-6">
<AuthorCard author={a} compact={false} hasLink={true} liteButtons={true} /> <AuthorCard author={a} compact={false} hasLink={true} liteButtons={true} />
</div> </div>
)} )}
</For> </For>
</div> </div>
<Show when={isReactionsLoaded()}>
<CommentsTree <CommentsTree
shoutId={props.article?.id} shoutId={props.article.id}
shoutSlug={props.article?.slug} shoutSlug={props.article.slug}
commentAuthors={props.article?.authors} commentAuthors={props.article.authors}
/> />
</Show>
</div> </div>
</div> </div>
</> </>

View File

@ -1,6 +1,13 @@
.rating { .rating {
align-items: center; align-items: center;
display: flex; display: flex;
&.isDownvoted .downvoteButton,
&.isUpvoted .upvoteButton {
background: #000;
border-color: #000;
color: #fff;
}
} }
.ratingValue { .ratingValue {

View File

@ -12,12 +12,17 @@ interface RatingControlProps {
export const RatingControl = (props: RatingControlProps) => { export const RatingControl = (props: RatingControlProps) => {
return ( return (
<div class={clsx(props.class, styles.rating)}> <div
<button class={styles.ratingControl} onClick={props.onDownvote}> class={clsx(styles.rating, props.class, {
[styles.isUpvoted]: props.isUpvoted,
[styles.isDownvoted]: props.isDownvoted
})}
>
<button class={clsx(styles.ratingControl, styles.downvoteButton)} onClick={props.onDownvote}>
&minus; &minus;
</button> </button>
<span class={styles.ratingValue}>{props?.rating || ''}</span> <span class={styles.ratingValue}>{props?.rating || ''}</span>
<button class={styles.ratingControl} onClick={props.onUpvote}> <button class={clsx(styles.ratingControl, styles.upvoteButton)} onClick={props.onUpvote}>
+ +
</button> </button>
</div> </div>

View File

@ -12,6 +12,11 @@ import stylesHeader from '../Nav/Header.module.scss'
import { getDescription } from '../../utils/meta' import { getDescription } from '../../utils/meta'
import { FeedArticlePopup } from './FeedArticlePopup' import { FeedArticlePopup } from './FeedArticlePopup'
import { useLocalize } from '../../context/localize' 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 { interface ArticleCardProps {
settings?: { settings?: {
@ -61,6 +66,13 @@ const getTitleAndSubtitle = (article: Shout): { title: string; subtitle: string
export const ArticleCard = (props: ArticleCardProps) => { export const ArticleCard = (props: ArticleCardProps) => {
const { t, lang } = useLocalize() const { t, lang } = useLocalize()
const { userSlug } = useSession()
const {
reactionEntities,
actions: { createReaction, deleteReaction, loadReactionsBy }
} = useReactions()
const mainTopic = const mainTopic =
props.article.topics.find((articleTopic) => articleTopic.slug === props.article.mainTopic) || props.article.topics.find((articleTopic) => articleTopic.slug === props.article.mainTopic) ||
props.article.topics[0] props.article.topics[0]
@ -73,7 +85,41 @@ export const ArticleCard = (props: ArticleCardProps) => {
const { title, subtitle } = getTitleAndSubtitle(props.article) 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 ( return (
<section <section
@ -165,7 +211,14 @@ export const ArticleCard = (props: ArticleCardProps) => {
<Show when={props.settings?.isFeedMode}> <Show when={props.settings?.isFeedMode}>
<section class={styles.shoutCardDetails}> <section class={styles.shoutCardDetails}>
<div class={styles.shoutCardDetailsContent}> <div class={styles.shoutCardDetailsContent}>
<RatingControl rating={stat?.rating} class={styles.shoutCardDetailsItem} /> <RatingControl
rating={stat.rating}
class={styles.shoutCardDetailsItem}
onUpvote={() => handleRatingChange(true)}
onDownvote={() => handleRatingChange(false)}
isUpvoted={isUpvoted()}
isDownvoted={isDownvoted()}
/>
<div class={clsx(styles.shoutCardDetailsItem, styles.shoutCardDetailsViewed)}> <div class={clsx(styles.shoutCardDetailsItem, styles.shoutCardDetailsViewed)}>
<Icon name="eye" class={clsx(styles.icon, styles.feedControlIcon)} /> <Icon name="eye" class={clsx(styles.icon, styles.feedControlIcon)} />

View File

@ -59,11 +59,18 @@ export const FeedView = () => {
}) })
const loadMore = async () => { const loadMore = async () => {
const { hasMore } = await loadShouts({ const { hasMore, newShouts } = await loadShouts({
filters: { visibility: 'community' }, filters: { visibility: 'community' },
limit: FEED_PAGE_SIZE, limit: FEED_PAGE_SIZE,
offset: sortedArticles().length offset: sortedArticles().length
}) })
loadReactionsBy({
by: {
shouts: newShouts.map((s) => s.slug)
}
})
setIsLoadMoreButtonVisible(hasMore) setIsLoadMoreButtonVisible(hasMore)
} }

View File

@ -1,6 +1,7 @@
import type { JSX } from 'solid-js' import type { JSX } from 'solid-js'
import { createContext, onCleanup, useContext } from 'solid-js' import { createContext, onCleanup, useContext } from 'solid-js'
import type { Reaction, ReactionBy, ReactionInput } from '../graphql/types.gen' import type { Reaction, ReactionBy, ReactionInput } from '../graphql/types.gen'
import { ReactionKind } from '../graphql/types.gen'
import { apiClient } from '../utils/apiClient' import { apiClient } from '../utils/apiClient'
import { createStore } from 'solid-js/store' import { createStore } from 'solid-js/store'
@ -41,12 +42,33 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => {
const createReaction = async (input: ReactionInput): Promise<void> => { const createReaction = async (input: ReactionInput): Promise<void> => {
const reaction = await apiClient.createReaction(input) 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<void> => { const deleteReaction = async (id: number): Promise<void> => {
const reaction = await apiClient.destroyReaction(id) const reaction = await apiClient.destroyReaction(id)
console.debug('[deleteReaction]:', reaction.id)
setReactionEntities((oldState) => ({ setReactionEntities((oldState) => ({
...oldState, ...oldState,
[reaction.id]: undefined [reaction.id]: undefined

View File

@ -2,16 +2,19 @@ import { createSignal, Show } from 'solid-js'
import { PageLayout } from '../../components/_shared/PageLayout' import { PageLayout } from '../../components/_shared/PageLayout'
import { Donate } from '../../components/Discours/Donate' import { Donate } from '../../components/Discours/Donate'
import { Icon } from '../../components/_shared/Icon' import { Icon } from '../../components/_shared/Icon'
import { Meta, Title } from '@solidjs/meta'
// const title = t('Support us') import { useLocalize } from '../../context/localize'
export const HelpPage = () => { export const HelpPage = () => {
const [indexExpanded, setIndexExpanded] = createSignal(true) const [indexExpanded, setIndexExpanded] = createSignal(true)
const { t } = useLocalize()
const toggleIndexExpanded = () => setIndexExpanded((oldExpanded) => !oldExpanded) const toggleIndexExpanded = () => setIndexExpanded((oldExpanded) => !oldExpanded)
return ( return (
<PageLayout> <PageLayout>
<Title>{t('Support us')}</Title>
<Meta name="description">Здесь можно поддержать Дискурс материально.</Meta> <Meta name="description">Здесь можно поддержать Дискурс материально.</Meta>
<Meta name="keywords">Discours.io, помощь, благотворительность</Meta> <Meta name="keywords">Discours.io, помощь, благотворительность</Meta>

View File

@ -126,24 +126,32 @@ const addSortedArticles = (articles: Shout[]) => {
export const loadShout = async (slug: string): Promise<void> => { export const loadShout = async (slug: string): Promise<void> => {
const newArticle = await apiClient.getShout(slug) const newArticle = await apiClient.getShout(slug)
addArticles([newArticle]) 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 }> => { export const loadShouts = async (
const newArticles = await apiClient.getShouts({ options: LoadShoutsOptions
): Promise<{ hasMore: boolean; newShouts: Shout[] }> => {
const newShouts = await apiClient.getShouts({
...options, ...options,
limit: options.limit + 1 limit: options.limit + 1
}) })
const hasMore = newArticles.length === options.limit + 1 const hasMore = newShouts.length === options.limit + 1
if (hasMore) { if (hasMore) {
newArticles.splice(-1) newShouts.splice(-1)
} }
addArticles(newArticles) addArticles(newShouts)
addSortedArticles(newArticles) addSortedArticles(newShouts)
return { hasMore } return { hasMore, newShouts }
} }
export const resetSortedArticles = () => { export const resetSortedArticles = () => {

View File

@ -278,7 +278,7 @@ export const apiClient = {
const resp = await publicGraphQLClient const resp = await publicGraphQLClient
.query(reactionsLoadBy, { by, limit: limit ?? 1000, offset: 0 }) .query(reactionsLoadBy, { by, limit: limit ?? 1000, offset: 0 })
.toPromise() .toPromise()
console.debug(resp) // console.debug(resp)
return resp.data.loadReactionsBy return resp.data.loadReactionsBy
}, },

View File

@ -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
)