Expo random top articles (#331)

* WIP

* done

---------

Co-authored-by: Igor Lobanov <igor.lobanov@onetwotrip.com>
This commit is contained in:
Igor Lobanov 2023-12-13 23:57:33 +01:00 committed by GitHub
parent f9b9d129dd
commit e5846deab7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 183 additions and 67 deletions

View File

@ -50,7 +50,6 @@ const pagesMap: Record<keyof typeof ROUTES, Component<PageProps>> = {
authorAbout: AuthorPage, authorAbout: AuthorPage,
inbox: InboxPage, inbox: InboxPage,
expo: ExpoPage, expo: ExpoPage,
expoLayout: ExpoPage,
connect: ConnectPage, connect: ConnectPage,
create: CreatePage, create: CreatePage,
edit: EditPage, edit: EditPage,

View File

@ -3,49 +3,89 @@ import { clsx } from 'clsx'
import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount, Show } from 'solid-js' import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount, Show } from 'solid-js'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { LoadShoutsOptions, Shout } from '../../../graphql/types.gen' import { LoadRandomTopShoutsParams, LoadShoutsOptions, Shout } from '../../../graphql/types.gen'
import { LayoutType } from '../../../pages/types' import { LayoutType } from '../../../pages/types'
import { router, useRouter } from '../../../stores/router' import { router } from '../../../stores/router'
import { loadShouts, resetSortedArticles, useArticlesStore } from '../../../stores/zine/articles' import { loadShouts, resetSortedArticles, useArticlesStore } from '../../../stores/zine/articles'
import { apiClient } from '../../../utils/apiClient'
import { getServerDate } from '../../../utils/getServerDate'
import { restoreScrollPosition, saveScrollPosition } from '../../../utils/scroll' import { restoreScrollPosition, saveScrollPosition } from '../../../utils/scroll'
import { splitToPages } from '../../../utils/splitToPages' import { splitToPages } from '../../../utils/splitToPages'
import { Button } from '../../_shared/Button' import { Button } from '../../_shared/Button'
import { ConditionalWrapper } from '../../_shared/ConditionalWrapper' import { ConditionalWrapper } from '../../_shared/ConditionalWrapper'
import { Loading } from '../../_shared/Loading' import { Loading } from '../../_shared/Loading'
import { ArticleCardSwiper } from '../../_shared/SolidSwiper/ArticleCardSwiper'
import { ArticleCard } from '../../Feed/ArticleCard' import { ArticleCard } from '../../Feed/ArticleCard'
import styles from './Expo.module.scss' import styles from './Expo.module.scss'
type Props = { type Props = {
shouts: Shout[] shouts: Shout[]
layout: LayoutType
} }
export const PRERENDERED_ARTICLES_COUNT = 28
export const PRERENDERED_ARTICLES_COUNT = 32
const LOAD_MORE_PAGE_SIZE = 16 const LOAD_MORE_PAGE_SIZE = 16
export const Expo = (props: Props) => { export const Expo = (props: Props) => {
const [isLoaded, setIsLoaded] = createSignal<boolean>(Boolean(props.shouts)) const [isLoaded, setIsLoaded] = createSignal<boolean>(Boolean(props.shouts))
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false) const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const [randomTopArticles, setRandomTopArticles] = createSignal<Shout[]>([])
const [randomTopMonthArticles, setRandomTopMonthArticles] = createSignal<Shout[]>([])
const { t } = useLocalize() const { t } = useLocalize()
const { page: getPage } = useRouter()
const getLayout = createMemo<LayoutType>(() => getPage().params['layout'] as LayoutType)
const { sortedArticles } = useArticlesStore({ const { sortedArticles } = useArticlesStore({
shouts: isLoaded() ? props.shouts : [], shouts: isLoaded() ? props.shouts : [],
}) })
const loadMore = async (count) => { const loadMore = async (count: number) => {
saveScrollPosition() saveScrollPosition()
const options: LoadShoutsOptions = { const options: LoadShoutsOptions = {
limit: count, limit: count,
offset: sortedArticles().length, offset: sortedArticles().length,
} }
options.filters = getLayout() ? { layout: getLayout() } : { excludeLayout: 'article' } options.filters = props.layout ? { layout: props.layout } : { excludeLayout: 'article' }
const { hasMore } = await loadShouts(options) const { hasMore } = await loadShouts(options)
setIsLoadMoreButtonVisible(hasMore) setIsLoadMoreButtonVisible(hasMore)
restoreScrollPosition() restoreScrollPosition()
} }
const loadRandomTopArticles = async () => {
const params: LoadRandomTopShoutsParams = {
filters: {
visibility: 'public',
},
limit: 10,
fromRandomCount: 100,
}
params.filters = props.layout ? { layout: props.layout } : { excludeLayout: 'article' }
const result = await apiClient.getRandomTopShouts(params)
setRandomTopArticles(result)
}
const loadRandomTopMonthArticles = async () => {
const now = new Date()
const fromDate = getServerDate(new Date(now.setMonth(now.getMonth() - 1)))
const params: LoadRandomTopShoutsParams = {
filters: {
visibility: 'public',
fromDate,
},
limit: 10,
fromRandomCount: 10,
}
params.filters = props.layout ? { layout: props.layout } : { excludeLayout: 'article' }
const result = await apiClient.getRandomTopShouts(params)
setRandomTopMonthArticles(result)
}
const pages = createMemo<Shout[][]>(() => const pages = createMemo<Shout[][]>(() =>
splitToPages(sortedArticles(), PRERENDERED_ARTICLES_COUNT, LOAD_MORE_PAGE_SIZE), splitToPages(sortedArticles(), PRERENDERED_ARTICLES_COUNT, LOAD_MORE_PAGE_SIZE),
) )
@ -65,12 +105,21 @@ export const Expo = (props: Props) => {
} }
}) })
onMount(() => {
loadRandomTopArticles()
loadRandomTopMonthArticles()
})
createEffect( createEffect(
on( on(
() => getLayout(), () => props.layout,
() => { () => {
resetSortedArticles() resetSortedArticles()
setRandomTopArticles([])
setRandomTopMonthArticles([])
loadMore(PRERENDERED_ARTICLES_COUNT + LOAD_MORE_PAGE_SIZE) loadMore(PRERENDERED_ARTICLES_COUNT + LOAD_MORE_PAGE_SIZE)
loadRandomTopArticles()
loadRandomTopMonthArticles()
}, },
{ defer: true }, { defer: true },
), ),
@ -89,49 +138,49 @@ export const Expo = (props: Props) => {
<Show when={sortedArticles().length > 0} fallback={<Loading />}> <Show when={sortedArticles().length > 0} fallback={<Loading />}>
<div class="wide-container"> <div class="wide-container">
<ul class={clsx('view-switcher', styles.navigation)}> <ul class={clsx('view-switcher', styles.navigation)}>
<li class={clsx({ 'view-switcher__item--selected': !getLayout() })}> <li class={clsx({ 'view-switcher__item--selected': !props.layout })}>
<ConditionalWrapper <ConditionalWrapper
condition={Boolean(getLayout())} condition={Boolean(props.layout)}
wrapper={(children) => <a href={getPagePath(router, 'expo')}>{children}</a>} wrapper={(children) => <a href={getPagePath(router, 'expo', { layout: '' })}>{children}</a>}
> >
<span class={clsx('linkReplacement')}>{t('All')}</span> <span class={clsx('linkReplacement')}>{t('All')}</span>
</ConditionalWrapper> </ConditionalWrapper>
</li> </li>
<li class={clsx({ 'view-switcher__item--selected': getLayout() === 'literature' })}> <li class={clsx({ 'view-switcher__item--selected': props.layout === 'literature' })}>
<ConditionalWrapper <ConditionalWrapper
condition={getLayout() !== 'literature'} condition={props.layout !== 'literature'}
wrapper={(children) => ( wrapper={(children) => (
<a href={getPagePath(router, 'expoLayout', { layout: 'literature' })}>{children}</a> <a href={getPagePath(router, 'expo', { layout: 'literature' })}>{children}</a>
)} )}
> >
<span class={clsx('linkReplacement')}>{t('Literature')}</span> <span class={clsx('linkReplacement')}>{t('Literature')}</span>
</ConditionalWrapper> </ConditionalWrapper>
</li> </li>
<li class={clsx({ 'view-switcher__item--selected': getLayout() === 'music' })}> <li class={clsx({ 'view-switcher__item--selected': props.layout === 'music' })}>
<ConditionalWrapper <ConditionalWrapper
condition={getLayout() !== 'music'} condition={props.layout !== 'music'}
wrapper={(children) => ( wrapper={(children) => (
<a href={getPagePath(router, 'expoLayout', { layout: 'music' })}>{children}</a> <a href={getPagePath(router, 'expo', { layout: 'music' })}>{children}</a>
)} )}
> >
<span class={clsx('linkReplacement')}>{t('Music')}</span> <span class={clsx('linkReplacement')}>{t('Music')}</span>
</ConditionalWrapper> </ConditionalWrapper>
</li> </li>
<li class={clsx({ 'view-switcher__item--selected': getLayout() === 'image' })}> <li class={clsx({ 'view-switcher__item--selected': props.layout === 'image' })}>
<ConditionalWrapper <ConditionalWrapper
condition={getLayout() !== 'image'} condition={props.layout !== 'image'}
wrapper={(children) => ( wrapper={(children) => (
<a href={getPagePath(router, 'expoLayout', { layout: 'image' })}>{children}</a> <a href={getPagePath(router, 'expo', { layout: 'image' })}>{children}</a>
)} )}
> >
<span class={clsx('linkReplacement')}>{t('Gallery')}</span> <span class={clsx('linkReplacement')}>{t('Gallery')}</span>
</ConditionalWrapper> </ConditionalWrapper>
</li> </li>
<li class={clsx({ 'view-switcher__item--selected': getLayout() === 'video' })}> <li class={clsx({ 'view-switcher__item--selected': props.layout === 'video' })}>
<ConditionalWrapper <ConditionalWrapper
condition={getLayout() !== 'video'} condition={props.layout !== 'video'}
wrapper={(children) => ( wrapper={(children) => (
<a href={getPagePath(router, 'expoLayout', { layout: 'video' })}>{children}</a> <a href={getPagePath(router, 'expo', { layout: 'video' })}>{children}</a>
)} )}
> >
<span class={clsx('cursorPointer linkReplacement')}>{t('Video')}</span> <span class={clsx('cursorPointer linkReplacement')}>{t('Video')}</span>
@ -139,7 +188,7 @@ export const Expo = (props: Props) => {
</li> </li>
</ul> </ul>
<div class="row"> <div class="row">
<For each={sortedArticles().slice(0, PRERENDERED_ARTICLES_COUNT)}> <For each={sortedArticles().slice(0, PRERENDERED_ARTICLES_COUNT / 2)}>
{(shout) => ( {(shout) => (
<div class="col-md-6 mt-md-5 col-sm-8 mt-sm-3"> <div class="col-md-6 mt-md-5 col-sm-8 mt-sm-3">
<ArticleCard <ArticleCard
@ -150,6 +199,23 @@ export const Expo = (props: Props) => {
</div> </div>
)} )}
</For> </For>
<Show when={randomTopArticles().length > 0} keyed={true}>
<ArticleCardSwiper title={t('Favorite')} slides={randomTopArticles()} />
</Show>
<For each={sortedArticles().slice(PRERENDERED_ARTICLES_COUNT / 2, PRERENDERED_ARTICLES_COUNT)}>
{(shout) => (
<div class="col-md-6 mt-md-5 col-sm-8 mt-sm-3">
<ArticleCard
article={shout}
settings={{ nodate: true, nosubtitle: true, noAuthorLink: true }}
desktopCoverSize="XS"
/>
</div>
)}
</For>
<Show when={randomTopMonthArticles().length > 0} keyed={true}>
<ArticleCardSwiper title={t('Top month articles')} slides={randomTopMonthArticles()} />
</Show>
<For each={pages()}> <For each={pages()}>
{(page) => ( {(page) => (
<For each={page}> <For each={page}>

View File

@ -36,10 +36,7 @@ export const SearchView = (props: Props) => {
const loadMore = async () => { const loadMore = async () => {
saveScrollPosition() saveScrollPosition()
const { hasMore } = await loadShouts({ const { hasMore } = await loadShouts({
filters: { filters: {},
title: query(),
body: query(),
},
offset: offset(), offset: offset(),
limit: LOAD_MORE_PAGE_SIZE, limit: LOAD_MORE_PAGE_SIZE,
}) })

View File

@ -0,0 +1,46 @@
import { gql } from '@urql/core'
export default gql`
query LoadRandomTopShoutsQuery($params: LoadRandomTopShoutsParams) {
loadRandomTopShouts(params: $params) {
id
title
lead
description
subtitle
slug
layout
cover
lead
# community
mainTopic
topics {
id
title
body
slug
stat {
shouts
authors
followers
}
}
authors {
id
name
slug
userpic
createdAt
bio
}
createdAt
publishedAt
stat {
viewed
reacted
rating
commented
}
}
}
`

View File

@ -116,14 +116,19 @@ export enum FollowingEntity {
Topic = 'TOPIC', Topic = 'TOPIC',
} }
export type LoadRandomTopShoutsParams = {
filters?: InputMaybe<LoadShoutsFilters>
fromRandomCount?: InputMaybe<Scalars['Int']['input']>
limit: Scalars['Int']['input']
}
export type LoadShoutsFilters = { export type LoadShoutsFilters = {
author?: InputMaybe<Scalars['String']['input']> author?: InputMaybe<Scalars['String']['input']>
body?: InputMaybe<Scalars['String']['input']>
days?: InputMaybe<Scalars['Int']['input']>
excludeLayout?: InputMaybe<Scalars['String']['input']> excludeLayout?: InputMaybe<Scalars['String']['input']>
fromDate?: InputMaybe<Scalars['String']['input']>
layout?: InputMaybe<Scalars['String']['input']> layout?: InputMaybe<Scalars['String']['input']>
reacted?: InputMaybe<Scalars['Boolean']['input']> reacted?: InputMaybe<Scalars['Boolean']['input']>
title?: InputMaybe<Scalars['String']['input']> toDate?: InputMaybe<Scalars['String']['input']>
topic?: InputMaybe<Scalars['String']['input']> topic?: InputMaybe<Scalars['String']['input']>
visibility?: InputMaybe<Scalars['String']['input']> visibility?: InputMaybe<Scalars['String']['input']>
} }
@ -367,6 +372,7 @@ export type Query = {
loadMessagesBy: Result loadMessagesBy: Result
loadMySubscriptions?: Maybe<MySubscriptionsQueryResult> loadMySubscriptions?: Maybe<MySubscriptionsQueryResult>
loadNotifications: NotificationsQueryResult loadNotifications: NotificationsQueryResult
loadRandomTopShouts: Array<Maybe<Shout>>
loadReactionsBy: Array<Maybe<Reaction>> loadReactionsBy: Array<Maybe<Reaction>>
loadRecipients: Result loadRecipients: Result
loadShout?: Maybe<Shout> loadShout?: Maybe<Shout>
@ -419,6 +425,10 @@ export type QueryLoadNotificationsArgs = {
params: NotificationsQueryParams params: NotificationsQueryParams
} }
export type QueryLoadRandomTopShoutsArgs = {
params?: InputMaybe<LoadRandomTopShoutsParams>
}
export type QueryLoadReactionsByArgs = { export type QueryLoadReactionsByArgs = {
by: ReactionBy by: ReactionBy
limit?: InputMaybe<Scalars['Int']['input']> limit?: InputMaybe<Scalars['Int']['input']>

View File

@ -1,4 +1,5 @@
import { ROUTES } from '../../stores/router' import { ROUTES } from '../../stores/router'
import { getServerRoute } from '../../utils/getServerRoute' import { getServerRoute } from '../../utils/getServerRoute'
export default getServerRoute(ROUTES.expo) // yes, it's a hack
export default getServerRoute(ROUTES.expo.replace(':layout?', '*'))

View File

@ -4,12 +4,16 @@ import type { PageProps } from '../types'
import { PRERENDERED_ARTICLES_COUNT } from '../../components/Views/Expo/Expo' import { PRERENDERED_ARTICLES_COUNT } from '../../components/Views/Expo/Expo'
import { apiClient } from '../../utils/apiClient' import { apiClient } from '../../utils/apiClient'
export const onBeforeRender = async (_pageContext: PageContext) => { export const onBeforeRender = async (pageContext: PageContext) => {
const { layout } = pageContext.routeParams
const expoShouts = await apiClient.getShouts({ const expoShouts = await apiClient.getShouts({
filters: { excludeLayout: 'article' }, filters: layout ? { layout } : { excludeLayout: 'article' },
limit: PRERENDERED_ARTICLES_COUNT, limit: PRERENDERED_ARTICLES_COUNT,
}) })
const pageProps: PageProps = { expoShouts, seo: { title: '' } } const pageProps: PageProps = { expoShouts, seo: { title: '' } }
return { return {
pageContext: { pageContext: {
pageProps, pageProps,

View File

@ -37,7 +37,7 @@ export const ExpoPage = (props: PageProps) => {
return ( return (
<PageLayout withPadding={true} zeroBottomPadding={true} title={title()}> <PageLayout withPadding={true} zeroBottomPadding={true} title={title()}>
<Topics /> <Topics />
<Expo shouts={props.expoShouts} /> <Expo shouts={props.expoShouts} layout={getLayout()} />
</PageLayout> </PageLayout>
) )
} }

View File

@ -1,4 +0,0 @@
import { ROUTES } from '../../stores/router'
import { getServerRoute } from '../../utils/getServerRoute'
export default getServerRoute(ROUTES.expoLayout)

View File

@ -1,21 +0,0 @@
import type { PageContext } from '../../renderer/types'
import type { PageProps } from '../types'
import { PRERENDERED_ARTICLES_COUNT } from '../../components/Views/Expo/Expo'
import { apiClient } from '../../utils/apiClient'
export const onBeforeRender = async (pageContext: PageContext) => {
const { layout } = pageContext.routeParams
const expoShouts = await apiClient.getShouts({
filters: { layout: layout },
limit: PRERENDERED_ARTICLES_COUNT,
})
const pageProps: PageProps = { expoShouts, seo: { title: '' } }
return {
pageContext: {
pageProps,
},
}
}

View File

@ -6,7 +6,7 @@ import { apiClient } from '../utils/apiClient'
export const onBeforeRender = async (pageContext: PageContext) => { export const onBeforeRender = async (pageContext: PageContext) => {
const { q } = pageContext.routeParams const { q } = pageContext.routeParams
const searchResults = await apiClient.getShouts({ filters: { title: q, body: q }, limit: 50 }) const searchResults = await apiClient.getShouts({ filters: {}, limit: 50 })
const pageProps: PageProps = { searchResults, seo: { title: '' } } const pageProps: PageProps = { searchResults, seo: { title: '' } }

View File

@ -24,7 +24,7 @@ export const SearchPage = (props: PageProps) => {
return return
} }
await loadShouts({ filters: { title: q(), body: q() }, limit: 50, offset: 0 }) await loadShouts({ filters: {}, limit: 50, offset: 0 })
setIsLoaded(true) setIsLoaded(true)
}) })

View File

@ -37,8 +37,7 @@ export const ROUTES = {
projects: '/about/projects', projects: '/about/projects',
termsOfUse: '/about/terms-of-use', termsOfUse: '/about/terms-of-use',
thanks: '/about/thanks', thanks: '/about/thanks',
expo: '/expo', expo: '/expo/:layout?',
expoLayout: '/expo/:layout',
profileSettings: '/profile/settings', profileSettings: '/profile/settings',
profileSecurity: '/profile/security', profileSecurity: '/profile/security',
profileSubscriptions: '/profile/subscriptions', profileSubscriptions: '/profile/subscriptions',

View File

@ -4,6 +4,7 @@ import { createLazyMemo } from '@solid-primitives/memo'
import { createSignal } from 'solid-js' import { createSignal } from 'solid-js'
import { apiClient } from '../../utils/apiClient' import { apiClient } from '../../utils/apiClient'
import { getServerDate } from '../../utils/getServerDate'
import { byStat } from '../../utils/sortby' import { byStat } from '../../utils/sortby'
import { addAuthorsByTopic } from './authors' import { addAuthorsByTopic } from './authors'
@ -193,11 +194,13 @@ type InitialState = {
const TOP_MONTH_ARTICLES_COUNT = 10 const TOP_MONTH_ARTICLES_COUNT = 10
export const loadTopMonthArticles = async (): Promise<void> => { export const loadTopMonthArticles = async (): Promise<void> => {
const now = new Date()
const fromDate = getServerDate(new Date(now.setMonth(now.getMonth() - 1)))
const articles = await apiClient.getShouts({ const articles = await apiClient.getShouts({
filters: { filters: {
visibility: 'public', visibility: 'public',
// TODO: replace with from, to fromDate,
days: 30,
}, },
order_by: 'rating_stat', order_by: 'rating_stat',
limit: TOP_MONTH_ARTICLES_COUNT, limit: TOP_MONTH_ARTICLES_COUNT,

View File

@ -19,6 +19,7 @@ import type {
NotificationsQueryParams, NotificationsQueryParams,
NotificationsQueryResult, NotificationsQueryResult,
MySubscriptionsQueryResult, MySubscriptionsQueryResult,
LoadRandomTopShoutsParams,
} from '../graphql/types.gen' } from '../graphql/types.gen'
import createArticle from '../graphql/mutation/article-create' import createArticle from '../graphql/mutation/article-create'
@ -43,6 +44,8 @@ import { getToken, privateGraphQLClient } from '../graphql/privateGraphQLClient'
import { publicGraphQLClient } from '../graphql/publicGraphQLClient' import { publicGraphQLClient } from '../graphql/publicGraphQLClient'
import shoutLoad from '../graphql/query/article-load' import shoutLoad from '../graphql/query/article-load'
import shoutsLoadBy from '../graphql/query/articles-load-by' import shoutsLoadBy from '../graphql/query/articles-load-by'
import shoutsLoadRandomTop from '../graphql/query/articles-load-random-top'
import articlesLoadRandomTop from '../graphql/query/articles-load-random-top'
import authCheckEmailQuery from '../graphql/query/auth-check-email' import authCheckEmailQuery from '../graphql/query/auth-check-email'
import authLoginQuery from '../graphql/query/auth-login' import authLoginQuery from '../graphql/query/auth-login'
import authorBySlug from '../graphql/query/author-by-slug' import authorBySlug from '../graphql/query/author-by-slug'
@ -350,6 +353,15 @@ export const apiClient = {
return resp.data.loadShouts return resp.data.loadShouts
}, },
getRandomTopShouts: async (params: LoadRandomTopShoutsParams): Promise<Shout[]> => {
const resp = await publicGraphQLClient.query(articlesLoadRandomTop, { params }).toPromise()
if (resp.error) {
console.error(resp)
}
return resp.data.loadRandomTopShouts
},
getMyFeed: async (options: LoadShoutsOptions) => { getMyFeed: async (options: LoadShoutsOptions) => {
const resp = await privateGraphQLClient.query(myFeed, { options }).toPromise() const resp = await privateGraphQLClient.query(myFeed, { options }).toPromise()

View File

@ -0,0 +1,4 @@
export const getServerDate = (date: Date): string => {
// 2023-12-31
return date.toISOString().slice(0, 10)
}