From 789a7497a3a5d3fcd62d1006e5b45885394fd4a4 Mon Sep 17 00:00:00 2001 From: Untone Date: Mon, 15 Jul 2024 17:28:08 +0300 Subject: [PATCH] load-more-wrapper-wip --- app.config.ts | 8 +- .../Nav/SearchModal/SearchModal.tsx | 8 +- src/components/Views/Expo/Expo.tsx | 268 ++++++++---------- src/components/_shared/Button/Button.tsx | 2 + src/components/_shared/LoadMoreWrapper.tsx | 51 ++++ src/context/feed.tsx | 14 +- src/routes/(main).tsx | 93 +++--- src/routes/feed/(feed).tsx | 21 +- src/routes/search/(search).tsx | 26 +- 9 files changed, 258 insertions(+), 233 deletions(-) create mode 100644 src/components/_shared/LoadMoreWrapper.tsx diff --git a/app.config.ts b/app.config.ts index cf581aa6..938a648a 100644 --- a/app.config.ts +++ b/app.config.ts @@ -34,18 +34,14 @@ export default defineConfig({ }, vite: { envPrefix: 'PUBLIC_', - plugins: [ - !isVercel && mkcert(), - nodePolyfills(polyfillOptions), - sassDts() - ], + plugins: [!isVercel && mkcert(), nodePolyfills(polyfillOptions), sassDts()], css: { preprocessorOptions: { scss: { additionalData: '@import "src/styles/imports";\n', includePaths: ['./public', './src/styles'] } - } as CSSOptions["preprocessorOptions"] + } as CSSOptions['preprocessorOptions'] } } } as SolidStartInlineConfig) diff --git a/src/components/Nav/SearchModal/SearchModal.tsx b/src/components/Nav/SearchModal/SearchModal.tsx index 1e43e20c..e809ff31 100644 --- a/src/components/Nav/SearchModal/SearchModal.tsx +++ b/src/components/Nav/SearchModal/SearchModal.tsx @@ -1,19 +1,15 @@ -import type { Shout } from '~/graphql/schema/core.gen' - import { For, Show, createResource, createSignal, onCleanup } from 'solid-js' import { debounce } from 'throttle-debounce' - import { Button } from '~/components/_shared/Button' import { Icon } from '~/components/_shared/Icon' import { useFeed } from '~/context/feed' import { useLocalize } from '~/context/localize' +import type { Shout } from '~/graphql/schema/core.gen' import { byScore } from '~/lib/sort' import { restoreScrollPosition, saveScrollPosition } from '~/utils/scroll' import { FEED_PAGE_SIZE } from '../../Views/Feed/Feed' - -import { SearchResultItem } from './SearchResultItem' - import styles from './SearchModal.module.scss' +import { SearchResultItem } from './SearchResultItem' // @@TODO handle empty article options after backend support (subtitle, cover, etc.) // @@TODO implement load more diff --git a/src/components/Views/Expo/Expo.tsx b/src/components/Views/Expo/Expo.tsx index 745b8a63..57ff31c3 100644 --- a/src/components/Views/Expo/Expo.tsx +++ b/src/components/Views/Expo/Expo.tsx @@ -1,25 +1,27 @@ -import { clsx } from 'clsx' -import { For, Show, createEffect, createSignal, on, onCleanup, onMount } from 'solid-js' - import { A } from '@solidjs/router' -import { Button } from '~/components/_shared/Button' +import { clsx } from 'clsx' +import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js' import { ConditionalWrapper } from '~/components/_shared/ConditionalWrapper' +import { LoadMoreWrapper } from '~/components/_shared/LoadMoreWrapper' import { Loading } from '~/components/_shared/Loading' import { ArticleCardSwiper } from '~/components/_shared/SolidSwiper/ArticleCardSwiper' +import { useFeed } from '~/context/feed' import { useGraphQL } from '~/context/graphql' import { useLocalize } from '~/context/localize' -import getShoutsQuery from '~/graphql/query/core/articles-load-by' +import { loadShouts } from '~/graphql/api/public' import getRandomTopShoutsQuery from '~/graphql/query/core/articles-load-random-top' import { LoadShoutsFilters, LoadShoutsOptions, Shout } from '~/graphql/schema/core.gen' +import { SHOUTS_PER_PAGE } from '~/routes/(main)' import { LayoutType } from '~/types/common' import { getUnixtime } from '~/utils/date' -import { restoreScrollPosition, saveScrollPosition } from '~/utils/scroll' import { ArticleCard } from '../../Feed/ArticleCard' import styles from './Expo.module.scss' type Props = { shouts: Shout[] - layout: LayoutType + topMonthShouts?: Shout[] + topRatedShouts?: Shout[] + layout?: LayoutType } export const PRERENDERED_ARTICLES_COUNT = 36 @@ -28,52 +30,28 @@ const LOAD_MORE_PAGE_SIZE = 12 export const Expo = (props: Props) => { const { t } = useLocalize() const { query } = useGraphQL() - const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false) const [favoriteTopArticles, setFavoriteTopArticles] = createSignal([]) const [reactedTopMonthArticles, setReactedTopMonthArticles] = createSignal([]) - const [articlesEndPage, setArticlesEndPage] = createSignal(PRERENDERED_ARTICLES_COUNT) const [expoShouts, setExpoShouts] = createSignal([]) - const getLoadShoutsFilters = (additionalFilters: LoadShoutsFilters = {}): LoadShoutsFilters => { - const filters = { ...additionalFilters } + const { feedByLayout, expoFeed, setExpoFeed } = useFeed() + const layouts = createMemo(() => + props.layout ? [props.layout] : ['audio', 'video', 'image', 'literature'] + ) - if (!filters.layouts) filters.layouts = [] - if (props.layout) { - filters.layouts.push(props.layout) - } else { - filters.layouts.push('audio', 'video', 'image', 'literature') - } - - return filters - } - - const loadMore = async (count: number) => { - const options: LoadShoutsOptions = { - filters: getLoadShoutsFilters(), - limit: count, - offset: expoShouts().length - } - - options.filters = props.layout - ? { layouts: [props.layout] } - : { layouts: ['audio', 'video', 'image', 'literature'] } - - const resp = await query(getShoutsQuery, options).toPromise() - const result = resp?.data?.load_shouts || [] - const hasMore = result.length !== options.limit + 1 && result.length !== 0 - setIsLoadMoreButtonVisible(hasMore) - - setExpoShouts((prev) => [...prev, ...result]) - } - - const loadMoreWithoutScrolling = async (count: number) => { - saveScrollPosition() - await loadMore(count) - restoreScrollPosition() + const loadMoreFiltered = async () => { + const limit = SHOUTS_PER_PAGE + const offset = (props.layout ? feedByLayout()[props.layout] : expoFeed())?.length + const filters: LoadShoutsFilters = { layouts: layouts(), featured: true } + const options: LoadShoutsOptions = { filters, limit, offset } + const shoutsFetcher = loadShouts(options) + const result = await shoutsFetcher() + result && setExpoFeed(result) + return result } const loadRandomTopArticles = async () => { const options: LoadShoutsOptions = { - filters: { ...getLoadShoutsFilters(), featured: true }, + filters: { layouts: layouts(), featured: true }, limit: 10, random_limit: 100 } @@ -84,19 +62,16 @@ export const Expo = (props: Props) => { const loadRandomTopMonthArticles = async () => { const now = new Date() const after = getUnixtime(new Date(now.setMonth(now.getMonth() - 1))) - const options: LoadShoutsOptions = { - filters: { ...getLoadShoutsFilters({ after }), reacted: true }, + filters: { layouts: layouts(), after, reacted: true }, limit: 10, random_limit: 10 } - const resp = await query(getRandomTopShoutsQuery, { options }).toPromise() setReactedTopMonthArticles(resp?.data?.load_shouts_random_top || []) } onMount(() => { - loadMore(PRERENDERED_ARTICLES_COUNT + LOAD_MORE_PAGE_SIZE) loadRandomTopArticles() loadRandomTopMonthArticles() }) @@ -106,11 +81,8 @@ export const Expo = (props: Props) => { () => props.layout, () => { setExpoShouts([]) - setIsLoadMoreButtonVisible(false) setFavoriteTopArticles([]) setReactedTopMonthArticles([]) - setArticlesEndPage(PRERENDERED_ARTICLES_COUNT) - loadMore(PRERENDERED_ARTICLES_COUNT + LOAD_MORE_PAGE_SIZE) loadRandomTopArticles() loadRandomTopMonthArticles() } @@ -120,108 +92,106 @@ export const Expo = (props: Props) => { onCleanup(() => { setExpoShouts([]) }) + const ExpoTabs = () => ( +
+ +
+ ) + const ExpoGrid = () => ( +
+
+ + {(shout) => ( +
+ +
+ )} +
+ 0} keyed={true}> + + + + {(shout) => ( +
+ +
+ )} +
+ 0} keyed={true}> + + + + {(shout) => ( +
+ +
+ )} +
+
+
+ ) - const handleLoadMoreClick = () => { - loadMoreWithoutScrolling(LOAD_MORE_PAGE_SIZE) - setArticlesEndPage((prev) => prev + LOAD_MORE_PAGE_SIZE) - } - console.log(props.layout) return (
-
- -
+ 0} fallback={}> -
-
- - {(shout) => ( -
- -
- )} -
- 0} keyed={true}> - - - - {(shout) => ( -
- -
- )} -
- 0} keyed={true}> - - - - {(shout) => ( -
- -
- )} -
-
- -
-
-
-
+ + +
) diff --git a/src/components/_shared/Button/Button.tsx b/src/components/_shared/Button/Button.tsx index 66172c0d..11434e0b 100644 --- a/src/components/_shared/Button/Button.tsx +++ b/src/components/_shared/Button/Button.tsx @@ -6,6 +6,7 @@ import styles from './Button.module.scss' export type ButtonVariant = 'primary' | 'secondary' | 'bordered' | 'inline' | 'light' | 'outline' | 'danger' type Props = { + title?: string value: string | JSX.Element size?: 'S' | 'M' | 'L' variant?: ButtonVariant @@ -28,6 +29,7 @@ export const Button = (props: Props) => { } props.ref = el }} + title={props.title || (typeof props.value === 'string' ? props.value : '')} onClick={props.onClick} type={props.type ?? 'button'} disabled={props.loading || props.disabled} diff --git a/src/components/_shared/LoadMoreWrapper.tsx b/src/components/_shared/LoadMoreWrapper.tsx new file mode 100644 index 00000000..811b8a9c --- /dev/null +++ b/src/components/_shared/LoadMoreWrapper.tsx @@ -0,0 +1,51 @@ +import { JSX, Show, createSignal, onMount } from 'solid-js' +import { Button } from '~/components/_shared/Button' +import { useLocalize } from '~/context/localize' +import { Author, Reaction, Shout } from '~/graphql/schema/core.gen' +import { restoreScrollPosition, saveScrollPosition } from '~/utils/scroll' + +type LoadMoreProps = { + loadFunction: (offset?: number) => void + pageSize: number + children: JSX.Element +} + +type Items = Shout[] | Author[] | Reaction[] + +export const LoadMoreWrapper = (props: LoadMoreProps) => { + const { t } = useLocalize() + const [items, setItems] = createSignal([]) + const [offset, setOffset] = createSignal(0) + const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(true) + const [isLoading, setIsLoading] = createSignal(false) + + const loadItems = async () => { + setIsLoading(true) + saveScrollPosition() + const newItems = await props.loadFunction(offset()) + if (!Array.isArray(newItems)) return + setItems((prev) => [...prev, ...newItems]) + setOffset((prev) => prev + props.pageSize) + setIsLoadMoreButtonVisible(newItems.length >= props.pageSize) + setIsLoading(false) + restoreScrollPosition() + } + + onMount(loadItems) + + return ( + <> + {props.children} + +
+
+
+ + ) +} diff --git a/src/context/feed.tsx b/src/context/feed.tsx index 0c094995..e1fd3867 100644 --- a/src/context/feed.tsx +++ b/src/context/feed.tsx @@ -1,6 +1,6 @@ import { createLazyMemo } from '@solid-primitives/memo' import { makePersisted } from '@solid-primitives/storage' -import { Accessor, JSX, createContext, createSignal, useContext } from 'solid-js' +import { Accessor, JSX, Setter, createContext, createSignal, useContext } from 'solid-js' import { loadFollowedShouts } from '~/graphql/api/private' import { loadShoutsSearch as fetchShoutsSearch, getShout, loadShouts } from '~/graphql/api/public' import { @@ -37,6 +37,10 @@ type FeedContextType = { loadTopFeed: () => Promise seen: Accessor<{ [slug: string]: number }> addSeen: (slug: string) => void + featuredFeed: Accessor + setFeaturedFeed: Setter + expoFeed: Accessor + setExpoFeed: Setter } const FeedContext = createContext({} as FeedContextType) @@ -46,6 +50,8 @@ export const useFeed = () => useContext(FeedContext) export const FeedProvider = (props: { children: JSX.Element }) => { const [sortedFeed, setSortedFeed] = createSignal([]) const [articleEntities, setArticleEntities] = createSignal<{ [articleSlug: string]: Shout }>({}) + const [featuredFeed, setFeaturedFeed] = createSignal([]) + const [expoFeed, setExpoFeed] = createSignal([]) const [topFeed, setTopFeed] = createSignal([]) const [topMonthFeed, setTopMonthFeed] = createSignal([]) const [feedByLayout, _setFeedByLayout] = createSignal<{ [layout: string]: Shout[] }>({}) @@ -236,7 +242,11 @@ export const FeedProvider = (props: { children: JSX.Element }) => { loadTopMonthFeed, loadTopFeed, seen, - addSeen + addSeen, + featuredFeed, + setFeaturedFeed, + expoFeed, + setExpoFeed }} > {props.children} diff --git a/src/routes/(main).tsx b/src/routes/(main).tsx index 9a921c4c..b7e5dc18 100644 --- a/src/routes/(main).tsx +++ b/src/routes/(main).tsx @@ -1,11 +1,12 @@ import { type RouteDefinition, type RouteSectionProps, createAsync } from '@solidjs/router' -import { Show, Suspense, createEffect, createSignal, onMount } from 'solid-js' +import { Show, createEffect } from 'solid-js' +import { LoadMoreWrapper } from '~/components/_shared/LoadMoreWrapper' +import { useFeed } from '~/context/feed' import { useTopics } from '~/context/topics' import { loadShouts, loadTopics } from '~/graphql/api/public' import { LoadShoutsOptions, Shout } from '~/graphql/schema/core.gen' import { byStat } from '~/lib/sort' import { SortFunction } from '~/types/common' -import { restoreScrollPosition, saveScrollPosition } from '~/utils/scroll' import { HomeView, HomeViewProps } from '../components/Views/Home' import { Loading } from '../components/_shared/Loading' import { PageLayout } from '../components/_shared/PageLayout' @@ -13,6 +14,15 @@ import { useLocalize } from '../context/localize' export const SHOUTS_PER_PAGE = 20 +const featuredLoader = (offset?: number) => { + const SHOUTS_PER_PAGE = 20 + return loadShouts({ + filters: { featured: true }, + limit: SHOUTS_PER_PAGE, + offset + }) +} + const fetchAllTopics = async () => { const allTopicsLoader = loadTopics() return await allTopicsLoader() @@ -65,66 +75,63 @@ export const route = { } satisfies RouteDefinition export default function HomePage(props: RouteSectionProps) { - const limit = 20 const { addTopics } = useTopics() const { t } = useLocalize() - const [featuredOffset, setFeaturedOffset] = createSignal(0) + const { + setFeaturedFeed, + featuredFeed, + topMonthFeed, + topViewedFeed, + topCommentedFeed, + topFeed: topRatedFeed + } = useFeed() - const featuredLoader = (offset?: number) => { - const result = loadShouts({ - filters: { featured: true }, - limit, - offset - }) - return result - } - - // async ssr-friendly router-level cached data source const data = createAsync(async (prev?: HomeViewProps) => { const topics = props.data?.topics || (await fetchAllTopics()) - const featuredShoutsLoader = featuredLoader(featuredOffset()) + const offset = prev?.featuredShouts?.length || 0 + const featuredShoutsLoader = featuredLoader(offset) + const loaded = await featuredShoutsLoader() const featuredShouts = [ ...(prev?.featuredShouts || []), - ...((await featuredShoutsLoader()) || props.data?.featuredShouts || []) + ...(loaded || props.data?.featuredShouts || []) ] const sortFn = byStat('viewed') - const topViewedShouts = featuredShouts?.sort(sortFn as SortFunction) || [] - const result = { + const topViewedShouts = featuredShouts.sort(sortFn as SortFunction) + return { ...prev, ...props.data, topViewedShouts, featuredShouts, topics } - return result }) - createEffect(() => data()?.topics && addTopics(data()?.topics || [])) - const [canLoadMoreFeatured, setCanLoadMoreFeatured] = createSignal(true) - const loadMoreFeatured = async () => { - saveScrollPosition() - const before = data()?.featuredShouts.length || 0 - featuredLoader(featuredOffset()) - setFeaturedOffset((o: number) => o + limit) - const after = data()?.featuredShouts.length || 0 - setTimeout(() => setCanLoadMoreFeatured((_) => before !== after), 1) - restoreScrollPosition() + createEffect(() => { + if (data()?.topics) { + console.debug('[routes.main] topics update') + addTopics(data()?.topics || []) + } + }) + + const loadMoreFeatured = async (offset?: number) => { + const shoutsLoader = featuredLoader(offset) + const loaded = await shoutsLoader() + loaded && setFeaturedFeed((prev: Shout[]) => [...prev, ...loaded]) } - - onMount(async () => await loadMoreFeatured()) - + const SHOUTS_PER_PAGE = 20 return ( - - }> - - -

- -

-
-
+ + 0} fallback={}> + + + + ) } diff --git a/src/routes/feed/(feed).tsx b/src/routes/feed/(feed).tsx index f39fee14..267abc95 100644 --- a/src/routes/feed/(feed).tsx +++ b/src/routes/feed/(feed).tsx @@ -1,7 +1,9 @@ import { RouteSectionProps, createAsync, useSearchParams } from '@solidjs/router' import { Client } from '@urql/core' -import { Show, createEffect, createSignal } from 'solid-js' +import { createSignal } from 'solid-js' +import { AUTHORS_PER_PAGE } from '~/components/Views/AllAuthors/AllAuthors' import { Feed } from '~/components/Views/Feed' +import { LoadMoreWrapper } from '~/components/_shared/LoadMoreWrapper' import { PageLayout } from '~/components/_shared/PageLayout' import { useLocalize } from '~/context/localize' import { ReactionsProvider } from '~/context/reactions' @@ -59,7 +61,6 @@ export default (props: RouteSectionProps) => { const { t } = useLocalize() const [offset, setOffset] = createSignal(0) const shouts = createAsync(async () => ({ ...props.data }) || (await loadMore())) - const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(true) const loadMore = async () => { const newOffset = offset() + SHOUTS_PER_PAGE setOffset(newOffset) @@ -75,7 +76,6 @@ export default (props: RouteSectionProps) => { } return await fetchPublishedShouts(newOffset) } - createEffect(() => setIsLoadMoreButtonVisible(offset() < (shouts()?.length || 0))) return ( ) => { key="feed" desc="Independent media project about culture, science, art and society with horizontal editing" > - - - - -

- -

-
+ + + + +
) } diff --git a/src/routes/search/(search).tsx b/src/routes/search/(search).tsx index c6cc3eee..f5ee1b5e 100644 --- a/src/routes/search/(search).tsx +++ b/src/routes/search/(search).tsx @@ -1,5 +1,5 @@ import { action, useSearchParams } from '@solidjs/router' -import { Show, Suspense, createEffect, createSignal, onCleanup } from 'solid-js' +import { Show, createEffect, createSignal, onCleanup } from 'solid-js' import { SearchView } from '~/components/Views/Search' import { Loading } from '~/components/_shared/Loading' @@ -48,20 +48,18 @@ export default () => { return ( - }> - }> - 0} - fallback={ - {t('Enter your search query')}}> -
{t('No results found')}
-
- } - > - -
+ }> + 0} + fallback={ + {t('Enter your search query')}}> +
{t('No results found')}
+
+ } + > +
-
+
) }