webapp/src/components/Views/Feed/Feed.tsx

431 lines
15 KiB
TypeScript
Raw Normal View History

import { getPagePath } from '@nanostores/router'
import { clsx } from 'clsx'
2024-02-04 11:25:21 +00:00
import { For, Show, createEffect, createMemo, createSignal, on, onMount } from 'solid-js'
import { useLocalize } from '../../../context/localize'
2024-05-07 15:17:31 +00:00
import { Meta } from '../../../context/meta'
import { useReactions } from '../../../context/reactions'
2023-12-26 10:05:15 +00:00
import { useSession } from '../../../context/session'
2024-05-06 23:44:25 +00:00
import { useTopics } from '../../../context/topics'
2023-12-18 01:15:49 +00:00
import { apiClient } from '../../../graphql/client/core'
2024-02-05 15:04:23 +00:00
import type { Author, LoadShoutsOptions, Reaction, Shout } from '../../../graphql/schema/core.gen'
import { router, useRouter } from '../../../stores/router'
2024-01-08 13:02:52 +00:00
import { showModal } from '../../../stores/ui'
2024-02-04 11:25:21 +00:00
import { resetSortedArticles, useArticlesStore } from '../../../stores/zine/articles'
import { useTopAuthorsStore } from '../../../stores/zine/topAuthors'
import { getImageUrl } from '../../../utils/getImageUrl'
2024-03-01 13:04:28 +00:00
import { byCreated } from '../../../utils/sortby'
import { CommentDate } from '../../Article/CommentDate'
2024-01-08 13:02:52 +00:00
import { getShareUrl } from '../../Article/SharePopup'
import { AuthorBadge } from '../../Author/AuthorBadge'
2023-12-31 05:01:34 +00:00
import { AuthorLink } from '../../Author/AuthorLink'
import { ArticleCard } from '../../Feed/ArticleCard'
import { Sidebar } from '../../Feed/Sidebar'
2024-02-03 08:16:47 +00:00
import { Modal } from '../../Nav/Modal'
2024-02-04 11:25:21 +00:00
import { DropDown } from '../../_shared/DropDown'
import { Icon } from '../../_shared/Icon'
import { InviteMembers } from '../../_shared/InviteMembers'
import { Loading } from '../../_shared/Loading'
import { ShareModal } from '../../_shared/ShareModal'
import stylesBeside from '../../Feed/Beside.module.scss'
import stylesTopic from '../../Feed/CardTopic.module.scss'
2024-02-04 11:25:21 +00:00
import styles from './Feed.module.scss'
2022-09-09 11:53:35 +00:00
export const FEED_PAGE_SIZE = 20
const UNRATED_ARTICLES_COUNT = 5
type FeedPeriod = 'week' | 'month' | 'year'
type VisibilityMode = 'all' | 'community' | 'featured'
type PeriodItem = {
value: FeedPeriod
title: string
}
type VisibilityItem = {
value: VisibilityMode
title: string
}
type FeedSearchParams = {
2024-05-01 14:33:37 +00:00
by: 'publish_date' | 'likes' | 'last_comment'
period: FeedPeriod
visibility: VisibilityMode
}
2024-04-24 12:48:19 +00:00
type Props = {
loadShouts: (options: LoadShoutsOptions) => Promise<{
hasMore: boolean
newShouts: Shout[]
}>
}
2023-12-24 13:08:04 +00:00
const getFromDate = (period: FeedPeriod): number => {
const now = new Date()
2023-12-24 13:08:04 +00:00
let d: Date = now
switch (period) {
case 'week': {
2023-12-24 13:08:04 +00:00
d = new Date(now.setDate(now.getDate() - 7))
2023-12-24 13:32:25 +00:00
break
}
case 'month': {
2023-12-24 13:08:04 +00:00
d = new Date(now.setMonth(now.getMonth() - 1))
2023-12-24 13:32:25 +00:00
break
}
case 'year': {
2023-12-24 13:08:04 +00:00
d = new Date(now.setFullYear(now.getFullYear() - 1))
2023-12-24 13:32:25 +00:00
break
}
}
2023-12-24 13:08:04 +00:00
return Math.floor(d.getTime() / 1000)
}
export const FeedView = (props: Props) => {
2023-12-20 16:54:20 +00:00
const { t } = useLocalize()
const monthPeriod: PeriodItem = { value: 'month', title: t('This month') }
const periods: PeriodItem[] = [
{ value: 'week', title: t('This week') },
monthPeriod,
{ value: 'year', title: t('This year') },
]
const visibilities: VisibilityItem[] = [
{ value: 'community', title: t('All') },
{ value: 'featured', title: t('Published') },
]
const { page, searchParams, changeSearchParams } = useRouter<FeedSearchParams>()
const [isLoading, setIsLoading] = createSignal(false)
const [isRightColumnLoaded, setIsRightColumnLoaded] = createSignal(false)
2024-02-04 17:40:15 +00:00
const { session } = useSession()
const { loadReactionsBy } = useReactions()
2022-11-18 02:23:04 +00:00
const { sortedArticles } = useArticlesStore()
2024-05-06 23:44:25 +00:00
const { topTopics } = useTopics()
2022-09-28 20:16:44 +00:00
const { topAuthors } = useTopAuthorsStore()
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
2023-02-17 09:21:02 +00:00
const [topComments, setTopComments] = createSignal<Reaction[]>([])
const [unratedArticles, setUnratedArticles] = createSignal<Shout[]>([])
const currentPeriod = createMemo(() => {
const period = periods.find((p) => p.value === searchParams().period)
if (!period) {
return monthPeriod
}
return period
})
const currentVisibility = createMemo(() => {
const visibility = visibilities.find((v) => v.value === searchParams().visibility)
if (!visibility) {
2024-04-24 12:48:19 +00:00
return visibilities[0]
}
return visibility
})
const loadUnratedArticles = async () => {
2024-01-18 12:52:02 +00:00
if (session()) {
const result = await apiClient.getUnratedShouts(UNRATED_ARTICLES_COUNT)
setUnratedArticles(result)
}
}
const loadTopComments = async () => {
2024-02-29 17:51:07 +00:00
const comments = await loadReactionsBy({ by: { comment: true }, limit: 50 })
2024-03-01 13:04:28 +00:00
setTopComments(comments.sort(byCreated).reverse())
}
onMount(() => {
loadMore()
// eslint-disable-next-line promise/catch-or-return
2024-01-18 12:52:02 +00:00
Promise.all([loadTopComments()]).finally(() => setIsRightColumnLoaded(true))
2023-12-25 04:01:52 +00:00
})
createEffect(() => {
if (session()?.access_token && !unratedArticles()) {
loadUnratedArticles()
}
})
createEffect(
on(
() => page().route + searchParams().by + searchParams().period + searchParams().visibility,
() => {
resetSortedArticles()
loadMore()
},
{ defer: true },
),
)
const loadFeedShouts = () => {
const options: LoadShoutsOptions = {
2022-11-15 14:24:50 +00:00
limit: FEED_PAGE_SIZE,
offset: sortedArticles().length,
}
2024-03-01 13:04:28 +00:00
if (searchParams()?.by) {
options.order_by = searchParams().by
}
const visibilityMode = searchParams().visibility
2024-04-24 12:48:19 +00:00
2024-01-05 18:38:35 +00:00
if (visibilityMode === 'all') {
options.filters = { ...options.filters }
} else if (visibilityMode) {
options.filters = {
...options.filters,
featured: visibilityMode === 'featured',
}
}
if (searchParams().by && searchParams().by !== 'publish_date') {
const period = searchParams().period || 'month'
2023-12-24 13:08:04 +00:00
options.filters = { after: getFromDate(period) }
}
2024-04-24 12:48:19 +00:00
return props.loadShouts(options)
}
const loadMore = async () => {
setIsLoading(true)
const { hasMore, newShouts } = await loadFeedShouts()
setIsLoading(false)
2023-02-28 17:13:14 +00:00
loadReactionsBy({
by: {
shouts: newShouts.map((s) => s.slug),
},
2023-02-28 17:13:14 +00:00
})
setIsLoadMoreButtonVisible(hasMore)
2022-09-13 09:59:04 +00:00
}
const ogImage = getImageUrl('production/image/logo_image.png')
const description = t(
'Independent media project about culture, science, art and society with horizontal editing',
)
const ogTitle = t('Feed')
2024-01-08 13:02:52 +00:00
const [shareData, setShareData] = createSignal<Shout | undefined>()
2024-03-01 13:04:28 +00:00
const handleShare = (shared: Shout | undefined) => {
2024-01-08 13:02:52 +00:00
showModal('share')
setShareData(shared)
}
2022-09-09 11:53:35 +00:00
return (
<div class="wide-container feed">
<Meta name="descprition" content={description} />
<Meta name="keywords" content={t('keywords')} />
<Meta name="og:type" content="article" />
<Meta name="og:title" content={ogTitle} />
<Meta name="og:image" content={ogImage} />
<Meta name="twitter:image" content={ogImage} />
<Meta name="og:description" content={description} />
<Meta name="twitter:card" content="summary_large_image" />
<Meta name="twitter:title" content={ogTitle} />
<Meta name="twitter:description" content={description} />
<div class="row">
<div class={clsx('col-md-5 col-xl-4', styles.feedNavigation)}>
<Sidebar />
</div>
2023-03-10 17:42:48 +00:00
<div class="col-md-12 offset-xl-1">
<div class={styles.filtersContainer}>
<ul class={clsx('view-switcher', styles.feedFilter)}>
<li
class={clsx({
'view-switcher__item--selected':
searchParams().by === 'publish_date' || !searchParams().by,
})}
>
<a href={getPagePath(router, page().route)}>{t('Recent')}</a>
</li>
{/*<li>*/}
{/* <a href="/feed/?by=views">{t('Most read')}</a>*/}
{/*</li>*/}
<li
class={clsx({
2024-03-01 13:04:28 +00:00
'view-switcher__item--selected': searchParams().by === 'likes',
})}
>
2024-03-01 13:04:28 +00:00
<span class="link" onClick={() => changeSearchParams({ by: 'likes' })}>
{t('Top rated')}
</span>
</li>
<li
class={clsx({
2024-05-01 14:33:37 +00:00
'view-switcher__item--selected': searchParams().by === 'last_comment',
})}
>
2024-05-01 14:33:37 +00:00
<span class="link" onClick={() => changeSearchParams({ by: 'last_comment' })}>
{t('Most commented')}
</span>
</li>
</ul>
<div class={styles.dropdowns}>
<Show when={searchParams().by && searchParams().by !== 'publish_date'}>
<DropDown
2024-01-06 04:06:58 +00:00
popupProps={{ horizontalAnchor: 'right' }}
options={periods}
currentOption={currentPeriod()}
triggerCssClass={styles.periodSwitcher}
onChange={(period: PeriodItem) => changeSearchParams({ period: period.value })}
/>
</Show>
<DropDown
2024-01-06 04:06:58 +00:00
popupProps={{ horizontalAnchor: 'right' }}
options={visibilities}
currentOption={currentVisibility()}
triggerCssClass={styles.periodSwitcher}
onChange={(visibility: VisibilityItem) =>
changeSearchParams({ visibility: visibility.value })
}
/>
</div>
</div>
<Show when={!isLoading()} fallback={<Loading />}>
<Show when={sortedArticles().length > 0}>
<For each={sortedArticles().slice(0, 4)}>
{(article) => (
2024-01-08 13:02:52 +00:00
<ArticleCard
onShare={(shared) => handleShare(shared)}
onInvite={() => showModal('inviteMembers')}
2024-01-08 13:02:52 +00:00
article={article}
settings={{ isFeedMode: true }}
desktopCoverSize="M"
/>
)}
</For>
2022-09-09 11:53:35 +00:00
<div class={styles.asideSection}>
<div class={stylesBeside.besideColumnTitle}>
<h4>{t('Popular authors')}</h4>
<a href="/authors">
{t('All authors')}
<Icon name="arrow-right" class={stylesBeside.icon} />
</a>
</div>
2023-08-12 14:17:00 +00:00
<ul class={stylesBeside.besideColumn}>
<For each={topAuthors().slice(0, 5)}>
{(author) => (
<li>
<AuthorBadge author={author} />
</li>
)}
</For>
</ul>
</div>
<For each={sortedArticles().slice(4)}>
{(article) => (
<ArticleCard article={article} settings={{ isFeedMode: true }} desktopCoverSize="M" />
)}
</For>
</Show>
<Show when={isLoadMoreButtonVisible()}>
<p class="load-more-container">
<button class="button" onClick={loadMore}>
{t('Load more')}
</button>
</p>
</Show>
</Show>
</div>
2023-03-10 17:42:48 +00:00
<aside class={clsx('col-md-7 col-xl-6 offset-xl-1', styles.feedAside)}>
<Show when={isRightColumnLoaded()}>
<Show when={topComments().length > 0}>
<section class={styles.asideSection}>
<h4>{t('Comments')}</h4>
<For each={topComments()}>
{(comment) => {
return (
<div class={styles.comment}>
<div class={clsx('text-truncate', styles.commentBody)}>
<a
href={`${getPagePath(router, 'article', {
slug: comment.shout.slug,
})}?commentId=${comment.id}`}
innerHTML={comment.body}
/>
</div>
<div class={styles.commentDetails}>
2023-12-20 16:54:20 +00:00
<AuthorLink author={comment.created_by as Author} size={'XS'} />
<CommentDate comment={comment} isShort={true} isLastInRow={true} />
</div>
<div class={clsx('text-truncate', styles.commentArticleTitle)}>
<a href={`/${comment.shout.slug}`}>{comment.shout.title}</a>
</div>
</div>
)
}}
</For>
</section>
</Show>
<Show when={topTopics().length > 0}>
<section class={styles.asideSection}>
<h4>{t('Hot topics')}</h4>
<For each={topTopics().slice(0, 7)}>
{(topic) => (
<span class={clsx(stylesTopic.shoutTopic, styles.topic)}>
<a href={`/topic/${topic.slug}`}>{topic.title}</a>{' '}
</span>
)}
</For>
</section>
</Show>
<section class={clsx(styles.asideSection, styles.pinnedLinks)}>
<h4>{t('Knowledge base')}</h4>
<ul class="nodash">
<li>
<a href={getPagePath(router, 'guide')}>Как устроен Дискурс</a>
</li>
<li>
<a href="/how-to-write-a-good-article">Как создать хороший текст</a>
</li>
<li>
<a href="#">Правила конструктивных дискуссий</a>
</li>
<li>
<a href={getPagePath(router, 'principles')}>Принципы сообщества</a>
</li>
</ul>
</section>
2024-01-13 14:14:35 +00:00
<Show when={unratedArticles()}>
<section class={clsx(styles.asideSection)}>
<h4>{t('Be the first to rate')}</h4>
<For each={unratedArticles()}>
{(article) => (
<ArticleCard article={article} settings={{ noimage: true, nodate: true }} />
)}
</For>
</section>
</Show>
</Show>
</aside>
</div>
2024-01-08 13:02:52 +00:00
<Show when={shareData()}>
<ShareModal
title={shareData().title}
description={shareData().description}
imageUrl={shareData().cover}
shareUrl={getShareUrl({ pathname: `/${shareData().slug}` })}
/>
</Show>
2024-02-03 05:19:15 +00:00
<Modal variant="medium" name="inviteCoauthors">
<InviteMembers variant={'coauthors'} title={t('Invite experts')} />
</Modal>
</div>
2022-09-09 11:53:35 +00:00
)
}