Merge branch 'rating-fixes' into 'dev'

likes/dislikes refactoring, who voted popup

See merge request discoursio/discoursio-webapp!34
This commit is contained in:
Igor 2023-03-03 18:37:48 +00:00
commit 27fc91bb87
14 changed files with 216 additions and 221 deletions

View File

@ -2,16 +2,14 @@ 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 { createEffect, createMemo, createSignal, For, Match, onMount, Show, Switch } from 'solid-js' import { createMemo, createSignal, For, Match, onMount, Show, Switch } from 'solid-js'
import type { Author, Shout } from '../../graphql/types.gen' import type { Author, Shout } from '../../graphql/types.gen'
import { ReactionKind } from '../../graphql/types.gen'
import MD from './MD' import MD from './MD'
import { SharePopup } from './SharePopup' import { SharePopup } from './SharePopup'
import { getDescription } from '../../utils/meta' import { getDescription } from '../../utils/meta'
import stylesHeader from '../Nav/Header.module.scss' import stylesHeader from '../Nav/Header.module.scss'
import styles from '../../styles/Article.module.scss' import styles from '../../styles/Article.module.scss'
import { RatingControl } from './RatingControl' import { ShoutRatingControl } from './ShoutRatingControl'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { CommentsTree } from './CommentsTree' import { CommentsTree } from './CommentsTree'
import { useSession } from '../../context/session' import { useSession } from '../../context/session'
@ -20,10 +18,8 @@ import Slider from '../_shared/Slider'
import { getPagePath } from '@nanostores/router' import { getPagePath } from '@nanostores/router'
import { router } from '../../stores/router' import { router } from '../../stores/router'
import { useReactions } from '../../context/reactions' import { useReactions } from '../../context/reactions'
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
@ -60,7 +56,7 @@ 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 { userSlug, session } = useSession() const { userSlug, isAuthenticated } = useSession()
const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false) const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false)
const formattedDate = createMemo(() => formatDate(new Date(props.article.createdAt))) const formattedDate = createMemo(() => formatDate(new Date(props.article.createdAt)))
@ -91,7 +87,7 @@ export const FullArticle = (props: ArticleProps) => {
setIsReactionsLoaded(true) setIsReactionsLoaded(true)
}) })
const canEdit = () => props.article.authors?.some((a) => a.slug === session()?.user?.slug) const canEdit = () => props.article.authors?.some((a) => a.slug === userSlug())
const bookmark = (ev) => { const bookmark = (ev) => {
// TODO: implement bookmark clicked // TODO: implement bookmark clicked
@ -106,68 +102,9 @@ export const FullArticle = (props: ArticleProps) => {
}) })
const { const {
reactionEntities, actions: { loadReactionsBy }
actions: { loadReactionsBy, createReaction, deleteReaction }
} = useReactions() } = useReactions()
const updateReactions = () => {
loadReactionsBy({
by: { shout: props.article.slug }
})
}
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 deleteShoutReaction = async (reactionKind: ReactionKind) => {
const reactionToDelete = Object.values(reactionEntities).find(
(r) =>
r.kind === reactionKind &&
r.createdBy.slug === userSlug() &&
r.shout.id === props.article.id &&
!r.replyTo
)
return deleteReaction(reactionToDelete.id)
}
const handleRatingChange = async (isUpvote: boolean) => {
if (isUpvote) {
if (isUpvoted()) {
await deleteShoutReaction(ReactionKind.Like)
} else if (isDownvoted()) {
await deleteShoutReaction(ReactionKind.Dislike)
} else {
await createReaction({
kind: ReactionKind.Like,
shout: props.article.id
})
}
} else {
if (isDownvoted()) {
await deleteShoutReaction(ReactionKind.Dislike)
} else if (isUpvoted()) {
await deleteShoutReaction(ReactionKind.Like)
} else {
await createReaction({
kind: ReactionKind.Dislike,
shout: props.article.id
})
}
}
loadShout(props.article.slug)
updateReactions()
}
createEffect(() => {
console.log('reactions', reactionEntities)
})
return ( return (
<> <>
<Title>{props.article.title}</Title> <Title>{props.article.title}</Title>
@ -248,14 +185,7 @@ export const FullArticle = (props: ArticleProps) => {
<div class="col-md-8 shift-content"> <div class="col-md-8 shift-content">
<div class={styles.shoutStats}> <div class={styles.shoutStats}>
<div class={styles.shoutStatsItem}> <div class={styles.shoutStatsItem}>
<RatingControl <ShoutRatingControl shout={props.article} class={styles.ratingControl} />
rating={props.article.stat?.rating}
class={styles.ratingControl}
onUpvote={() => handleRatingChange(true)}
onDownvote={() => handleRatingChange(false)}
isUpvoted={isUpvoted()}
isDownvoted={isDownvoted()}
/>
</div> </div>
<Show when={props.article.stat?.viewed}> <Show when={props.article.stat?.viewed}>
@ -299,7 +229,7 @@ export const FullArticle = (props: ArticleProps) => {
</div> </div>
</div> </div>
<div class={styles.help}> <div class={styles.help}>
<Show when={session()?.token}> <Show when={isAuthenticated()}>
<button class="button">{t('Cooperate')}</button> <button class="button">{t('Cooperate')}</button>
</Show> </Show>
<Show when={canEdit()}> <Show when={canEdit()}>

View File

@ -1,30 +0,0 @@
import styles from './RatingControl.module.scss'
import { clsx } from 'clsx'
interface RatingControlProps {
rating?: number
class?: string
onUpvote: () => Promise<void> | void
onDownvote: () => Promise<void> | void
isUpvoted: boolean
isDownvoted: boolean
}
export const RatingControl = (props: RatingControlProps) => {
return (
<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={clsx(styles.ratingControl, styles.upvoteButton)} onClick={props.onUpvote}>
+
</button>
</div>
)
}

View File

@ -0,0 +1,42 @@
.rating {
align-items: center;
display: flex;
&.isDownvoted .downvoteButton,
&.isUpvoted .upvoteButton {
background: #000;
border-color: #000;
color: #fff;
}
}
.ratingValue {
font-weight: bold;
margin: 0 4px;
padding: 0 4px;
cursor: pointer;
&:hover {
background-color: #000;
color: #fff;
}
}
.ratingControl {
align-items: center;
border: 2px solid;
border-radius: 100%;
display: flex;
justify-content: center;
height: 0.9em;
line-height: 0;
font-size: 1.6em;
padding: 0;
width: 0.9em;
&:hover {
background: #000;
border-color: #000;
color: #fff;
}
}

View File

@ -0,0 +1,108 @@
import styles from './ShoutRatingControl.module.scss'
import { clsx } from 'clsx'
import { createMemo, For, Match, Switch } from 'solid-js'
import { Author, ReactionKind, Shout } from '../../graphql/types.gen'
import { loadShout } from '../../stores/zine/articles'
import { useSession } from '../../context/session'
import { useReactions } from '../../context/reactions'
import { Button } from '../_shared/Button'
import Userpic from '../Author/Userpic'
import { AuthorCard } from '../Author/Card'
import { Popup } from '../_shared/Popup'
interface ShoutRatingControlProps {
shout: Shout
class?: string
}
export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
const { userSlug } = useSession()
const {
reactionEntities,
actions: { createReaction, deleteReaction, loadReactionsBy }
} = useReactions()
const checkReaction = (reactionKind: ReactionKind) =>
Object.values(reactionEntities).some(
(r) =>
r.kind === reactionKind &&
r.createdBy.slug === userSlug() &&
r.shout.id === props.shout.id &&
!r.replyTo
)
const isUpvoted = createMemo(() => checkReaction(ReactionKind.Like))
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
const shoutRatingReactions = createMemo(() =>
Object.values(reactionEntities).filter(
(r) => [ReactionKind.Like, ReactionKind.Dislike].includes(r.kind) && r.shout.id === props.shout.id
)
)
const deleteShoutReaction = async (reactionKind: ReactionKind) => {
const reactionToDelete = Object.values(reactionEntities).find(
(r) =>
r.kind === reactionKind &&
r.createdBy.slug === userSlug() &&
r.shout.id === props.shout.id &&
!r.replyTo
)
return deleteReaction(reactionToDelete.id)
}
const handleRatingChange = async (isUpvote: boolean) => {
if (isUpvoted()) {
await deleteShoutReaction(ReactionKind.Like)
} else if (isDownvoted()) {
await deleteShoutReaction(ReactionKind.Dislike)
} else {
await createReaction({
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
shout: props.shout.id
})
}
loadShout(props.shout.slug)
loadReactionsBy({
by: { shout: props.shout.slug }
})
}
return (
<div
class={clsx(styles.rating, props.class, {
[styles.isUpvoted]: isUpvoted(),
[styles.isDownvoted]: isDownvoted()
})}
>
<button
class={clsx(styles.ratingControl, styles.downvoteButton)}
onClick={() => handleRatingChange(false)}
>
&minus;
</button>
<Popup trigger={<span class={styles.ratingValue}>{props.shout.stat.rating}</span>} variant="tiny">
<ul class={clsx('nodash')}>
<For each={shoutRatingReactions()}>
{(reaction) => (
<li>
{reaction.kind === ReactionKind.Like ? <>+1</> : <>&minus;1</>} {reaction.createdBy.name}
</li>
)}
</For>
</ul>
</Popup>
<button
class={clsx(styles.ratingControl, styles.upvoteButton)}
onClick={() => handleRatingChange(true)}
>
+
</button>
</div>
)
}

View File

@ -0,0 +1,41 @@
import styles from './AuthorRatingControl.module.scss'
import { clsx } from 'clsx'
import type { Author } from '../../graphql/types.gen'
interface AuthorRatingControlProps {
author: Author
class?: string
}
export const AuthorRatingControl = (props: AuthorRatingControlProps) => {
const isUpvoted = false
const isDownvoted = false
const handleRatingChange = (isUpvote: boolean) => {
console.log('handleRatingChange', { isUpvote })
}
return (
<div
class={clsx(styles.rating, props.class, {
[styles.isUpvoted]: isUpvoted,
[styles.isDownvoted]: isDownvoted
})}
>
<button
class={clsx(styles.ratingControl, styles.downvoteButton)}
onClick={() => handleRatingChange(false)}
>
&minus;
</button>
{/*TODO*/}
<span class={styles.ratingValue}>{123}</span>
<button
class={clsx(styles.ratingControl, styles.upvoteButton)}
onClick={() => handleRatingChange(true)}
>
+
</button>
</div>
)
}

View File

@ -6,17 +6,13 @@ import { Icon } from '../_shared/Icon'
import styles from './Card.module.scss' import styles from './Card.module.scss'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { CardTopic } from './CardTopic' import { CardTopic } from './CardTopic'
import { RatingControl } from '../Article/RatingControl' import { ShoutRatingControl } from '../Article/ShoutRatingControl'
import { getShareUrl, SharePopup } from '../Article/SharePopup' import { getShareUrl, SharePopup } from '../Article/SharePopup'
import stylesHeader from '../Nav/Header.module.scss' 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 { useReactions } from '../../context/reactions'
import { checkReaction } from '../../utils/checkReaction'
import { useSession } from '../../context/session'
interface ArticleCardProps { interface ArticleCardProps {
settings?: { settings?: {
@ -66,13 +62,6 @@ 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]
@ -85,57 +74,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
const { title, subtitle } = getTitleAndSubtitle(props.article) const { title, subtitle } = getTitleAndSubtitle(props.article)
const { cover, layout, slug, authors, stat, body, id } = props.article const { cover, layout, slug, authors, stat, body } = 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 deleteShoutReaction = async (reactionKind: ReactionKind) => {
const reactionToDelete = Object.values(reactionEntities).find(
(r) => r.kind === reactionKind && r.createdBy.slug === userSlug() && r.shout.id === id && !r.replyTo
)
return deleteReaction(reactionToDelete.id)
}
const handleRatingChange = async (isUpvote: boolean) => {
if (isUpvote) {
if (isUpvoted()) {
await deleteShoutReaction(ReactionKind.Like)
} else if (isDownvoted()) {
await deleteShoutReaction(ReactionKind.Dislike)
} else {
await createReaction({
kind: ReactionKind.Like,
shout: id
})
}
} else {
if (isDownvoted()) {
await deleteShoutReaction(ReactionKind.Dislike)
} else if (isUpvoted()) {
await deleteShoutReaction(ReactionKind.Like)
} else {
await createReaction({
kind: ReactionKind.Dislike,
shout: id
})
}
}
loadShout(slug)
updateReactions()
}
return ( return (
<section <section
@ -227,14 +166,7 @@ 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 <ShoutRatingControl shout={props.article} class={styles.shoutCardDetailsItem} />
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

@ -1,6 +1,9 @@
import style from './CardTopic.module.scss' import { clsx } from 'clsx'
import { getPagePath } from '@nanostores/router'
import { router } from '../../stores/router'
import styles from './CardTopic.module.scss'
interface CardTopicProps { type CardTopicProps = {
title: string title: string
slug: string slug: string
isFloorImportant?: boolean isFloorImportant?: boolean
@ -9,12 +12,11 @@ interface CardTopicProps {
export const CardTopic = (props: CardTopicProps) => { export const CardTopic = (props: CardTopicProps) => {
return ( return (
<div <div
class={style.shoutTopic} class={clsx(styles.shoutTopic, {
classList={{ [styles.shoutTopicFloorImportant]: props.isFloorImportant
[style.shoutTopicFloorImportant]: props.isFloorImportant })}
}}
> >
<a href={`/topic/${props.slug}`}>{props.title}</a> <a href={getPagePath(router, 'topic', { slug: props.slug })}>{props.title}</a>
</div> </div>
) )
} }

View File

@ -10,12 +10,6 @@ type FeedArticlePopupProps = {
description: string description: string
} & Omit<PopupProps, 'children'> } & Omit<PopupProps, 'children'>
export const getShareUrl = (params: { pathname?: string } = {}) => {
if (typeof location === 'undefined') return ''
const pathname = params.pathname ?? location.pathname
return location.origin + pathname
}
export const FeedArticlePopup = (props: FeedArticlePopupProps) => { export const FeedArticlePopup = (props: FeedArticlePopupProps) => {
const { t } = useLocalize() const { t } = useLocalize()
return ( return (

View File

@ -14,15 +14,13 @@ export const ProfilePopup = (props: ProfilePopupProps) => {
actions: { signOut } actions: { signOut }
} = useSession() } = useSession()
const { t, lang } = useLocalize() const { t } = useLocalize()
return ( return (
<Popup {...props} horizontalAnchor="right" variant="bordered"> <Popup {...props} horizontalAnchor="right" variant="bordered">
<ul class="nodash"> <ul class="nodash">
<li> <li>
<a href={getPagePath(router, 'author', { slug: userSlug(), lang: lang() } as never)}> <a href={getPagePath(router, 'author', { slug: userSlug() })}>{t('Profile')}</a>
{t('Profile')}
</a>
</li> </li>
<li> <li>
<a href="#">{t('Drafts')}</a> <a href="#">{t('Drafts')}</a>

View File

@ -1,10 +0,0 @@
import { FullArticle } from '../Article/FullArticle'
import type { Shout } from '../../graphql/types.gen'
interface ArticlePageProps {
article: Shout
}
export const ArticleView = (props: ArticlePageProps) => {
return <FullArticle article={props.article} />
}

View File

@ -9,7 +9,6 @@ import { loadShouts, useArticlesStore } from '../../stores/zine/articles'
import { useRouter } from '../../stores/router' import { useRouter } from '../../stores/router'
import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll' import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll'
import { splitToPages } from '../../utils/splitToPages' import { splitToPages } from '../../utils/splitToPages'
import { RatingControl } from '../Article/RatingControl'
import styles from './Author.module.scss' import styles from './Author.module.scss'
import stylesArticle from '../../styles/Article.module.scss' import stylesArticle from '../../styles/Article.module.scss'
import { clsx } from 'clsx' import { clsx } from 'clsx'
@ -19,6 +18,7 @@ import { AuthorCard } from '../Author/Card'
import { apiClient } from '../../utils/apiClient' import { apiClient } from '../../utils/apiClient'
import { Comment } from '../Article/Comment' import { Comment } from '../Article/Comment'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
import { AuthorRatingControl } from '../Author/AuthorRatingControl'
type AuthorProps = { type AuthorProps = {
shouts: Shout[] shouts: Shout[]
@ -138,7 +138,6 @@ export const AuthorView = (props: AuthorProps) => {
</div> </div>
<div class={clsx('col-md-4', styles.additionalControls)}> <div class={clsx('col-md-4', styles.additionalControls)}>
<Popup <Popup
{...props}
trigger={ trigger={
<div class={styles.subscribers}> <div class={styles.subscribers}>
<Switch> <Switch>
@ -179,7 +178,7 @@ export const AuthorView = (props: AuthorProps) => {
<div class={styles.ratingContainer}> <div class={styles.ratingContainer}>
{t('Karma')} {t('Karma')}
<RatingControl rating={19} class={styles.ratingControl} /> <AuthorRatingControl author={props.author} class={styles.ratingControl} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,12 +1,12 @@
import { createMemo, onMount, Show } from 'solid-js' import { createMemo, onMount, Show } from 'solid-js'
import type { Shout } from '../graphql/types.gen' import type { Shout } from '../graphql/types.gen'
import { PageLayout } from '../components/_shared/PageLayout' import { PageLayout } from '../components/_shared/PageLayout'
import { ArticleView } from '../components/Views/Article'
import type { PageProps } from './types' import type { PageProps } from './types'
import { loadShout, useArticlesStore } from '../stores/zine/articles' import { loadShout, useArticlesStore } from '../stores/zine/articles'
import { useRouter } from '../stores/router' import { useRouter } from '../stores/router'
import { Loading } from '../components/_shared/Loading' import { Loading } from '../components/_shared/Loading'
import { ReactionsProvider } from '../context/reactions' import { ReactionsProvider } from '../context/reactions'
import { FullArticle } from '../components/Article/FullArticle'
export const ArticlePage = (props: PageProps) => { export const ArticlePage = (props: PageProps) => {
const shouts = props.article ? [props.article] : [] const shouts = props.article ? [props.article] : []
@ -50,7 +50,7 @@ export const ArticlePage = (props: PageProps) => {
<PageLayout headerTitle={article()?.title || ''} articleBody={article()?.body} cover={article()?.cover}> <PageLayout headerTitle={article()?.title || ''} articleBody={article()?.body} cover={article()?.cover}>
<ReactionsProvider> <ReactionsProvider>
<Show when={Boolean(article())} fallback={<Loading />}> <Show when={Boolean(article())} fallback={<Loading />}>
<ArticleView article={article()} /> <FullArticle article={article()} />
</Show> </Show>
</ReactionsProvider> </ReactionsProvider>
</PageLayout> </PageLayout>

View File

@ -1,11 +0,0 @@
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
)