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) => {
const [isCommentsLoading, setIsCommentsLoading] = createSignal(false)
const [commentsOrder, setCommentsOrder] = createSignal<CommentsOrder>('createdAt')
const {
reactionEntities,
actions: { loadReactionsBy, createReaction }
actions: { createReaction }
} = useReactions()
const { t } = useLocalize()
// TODO: server side?
const [newReactionsCount, setNewReactionsCount] = createSignal<number>(0)
const [newReactions, setNewReactions] = createSignal<Reaction[]>([])
@ -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<boolean>(false)
@ -118,80 +110,78 @@ export const CommentsTree = (props: Props) => {
}
return (
<div>
<Show when={!isCommentsLoading()} fallback={<Loading />}>
<div class={styles.commentsHeaderWrapper}>
<h2 id="comments" class={styles.commentsHeader}>
{t('Comments')} {comments().length.toString() || ''}
<Show when={newReactionsCount() > 0}>
<span class={styles.newReactions}>&nbsp;+{newReactionsCount()}</span>
</Show>
</h2>
<>
<div class={styles.commentsHeaderWrapper}>
<h2 id="comments" class={styles.commentsHeader}>
{t('Comments')} {comments().length.toString() || ''}
<Show when={newReactionsCount() > 0}>
<span class={styles.newReactions}>&nbsp;+{newReactionsCount()}</span>
</Show>
</h2>
<ul class={clsx(styles.commentsViewSwitcher, 'view-switcher')}>
<Show when={newReactionsCount() > 0}>
<li classList={{ selected: commentsOrder() === 'newOnly' }}>
<Button
variant="inline"
value={t('New only')}
onClick={() => {
setCommentsOrder('newOnly')
}}
/>
</li>
</Show>
<li classList={{ selected: commentsOrder() === 'createdAt' }}>
<ul class={clsx(styles.commentsViewSwitcher, 'view-switcher')}>
<Show when={newReactionsCount() > 0}>
<li classList={{ selected: commentsOrder() === 'newOnly' }}>
<Button
variant="inline"
value={t('By time')}
value={t('New only')}
onClick={() => {
setCommentsOrder('createdAt')
setCommentsOrder('newOnly')
}}
/>
</li>
<li classList={{ selected: commentsOrder() === 'rating' }}>
<Button
variant="inline"
value={t('By rating')}
onClick={() => {
setCommentsOrder('rating')
}}
/>
</li>
</ul>
</div>
<ul class={styles.comments}>
<For each={sortedComments().filter((r) => !r.replyTo)}>
{(reaction) => (
<Comment
sortedComments={sortedComments()}
isArticleAuthor={Boolean(props.commentAuthors.some((a) => a.slug === session()?.user.slug))}
comment={reaction}
/>
)}
</For>
</Show>
<li classList={{ selected: commentsOrder() === 'createdAt' }}>
<Button
variant="inline"
value={t('By time')}
onClick={() => {
setCommentsOrder('createdAt')
}}
/>
</li>
<li classList={{ selected: commentsOrder() === 'rating' }}>
<Button
variant="inline"
value={t('By rating')}
onClick={() => {
setCommentsOrder('rating')
}}
/>
</li>
</ul>
<ShowIfAuthenticated
fallback={
<div class={styles.signInMessage} id="comments">
{t('To write a comment, you must')}&nbsp;
<a href="?modal=auth&mode=register" class={styles.link}>
{t('sign up')}
</a>
&nbsp;{t('or')}&nbsp;
<a href="?modal=auth&mode=login" class={styles.link}>
{t('sign in')}
</a>
</div>
}
>
<CommentEditor
placeholder={t('Write a comment...')}
clear={submitted()}
onSubmit={(value) => handleSubmitComment(value)}
/>
</ShowIfAuthenticated>
</Show>
</div>
</div>
<ul class={styles.comments}>
<For each={sortedComments().filter((r) => !r.replyTo)}>
{(reaction) => (
<Comment
sortedComments={sortedComments()}
isArticleAuthor={Boolean(props.commentAuthors.some((a) => a.slug === session()?.user.slug))}
comment={reaction}
/>
)}
</For>
</ul>
<ShowIfAuthenticated
fallback={
<div class={styles.signInMessage} id="comments">
{t('To write a comment, you must')}&nbsp;
<a href="?modal=auth&mode=register" class={styles.link}>
{t('sign up')}
</a>
&nbsp;{t('or')}&nbsp;
<a href="?modal=auth&mode=login" class={styles.link}>
{t('sign in')}
</a>
</div>
}
>
<CommentEditor
placeholder={t('Write a comment...')}
clear={submitted()}
onSubmit={(value) => handleSubmitComment(value)}
/>
</ShowIfAuthenticated>
</>
)
}

View File

@ -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 (
<>
<Title>{props.article.title}</Title>
@ -200,8 +235,10 @@ export const FullArticle = (props: ArticleProps) => {
<RatingControl
rating={props.article.stat?.rating}
class={styles.ratingControl}
onUpvote={handleUpvote}
onDownvote={handleDownvote}
onUpvote={() => handleRatingChange(true)}
onDownvote={() => handleRatingChange(false)}
isUpvoted={isUpvoted()}
isDownvoted={isDownvoted()}
/>
</div>
@ -265,22 +302,24 @@ export const FullArticle = (props: ArticleProps) => {
</div>
<div class={styles.shoutAuthorsList}>
<Show when={props.article?.authors?.length > 1}>
<Show when={props.article.authors.length > 1}>
<h4>{t('Authors')}</h4>
</Show>
<For each={props.article?.authors}>
{(a: Author) => (
<For each={props.article.authors}>
{(a) => (
<div class="col-xl-6">
<AuthorCard author={a} compact={false} hasLink={true} liteButtons={true} />
</div>
)}
</For>
</div>
<CommentsTree
shoutId={props.article?.id}
shoutSlug={props.article?.slug}
commentAuthors={props.article?.authors}
/>
<Show when={isReactionsLoaded()}>
<CommentsTree
shoutId={props.article.id}
shoutSlug={props.article.slug}
commentAuthors={props.article.authors}
/>
</Show>
</div>
</div>
</>

View File

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

View File

@ -12,12 +12,17 @@ interface RatingControlProps {
export const RatingControl = (props: RatingControlProps) => {
return (
<div class={clsx(props.class, styles.rating)}>
<button class={styles.ratingControl} onClick={props.onDownvote}>
<div
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;
</button>
<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>
</div>

View File

@ -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 (
<section
@ -165,7 +211,14 @@ export const ArticleCard = (props: ArticleCardProps) => {
<Show when={props.settings?.isFeedMode}>
<section class={styles.shoutCardDetails}>
<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)}>
<Icon name="eye" class={clsx(styles.icon, styles.feedControlIcon)} />

View File

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

View File

@ -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<void> => {
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 reaction = await apiClient.destroyReaction(id)
console.debug('[deleteReaction]:', reaction.id)
setReactionEntities((oldState) => ({
...oldState,
[reaction.id]: undefined

View File

@ -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 (
<PageLayout>
<Title>{t('Support us')}</Title>
<Meta name="description">Здесь можно поддержать Дискурс материально.</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> => {
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 = () => {

View File

@ -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
},

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
)